pyfenixcontrol 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.
- pyfenixcontrol/__init__.py +46 -0
- pyfenixcontrol/auth.py +372 -0
- pyfenixcontrol/client.py +356 -0
- pyfenixcontrol/const.py +75 -0
- pyfenixcontrol/exceptions.py +27 -0
- pyfenixcontrol/models.py +230 -0
- pyfenixcontrol/py.typed +0 -0
- pyfenixcontrol-0.1.0.dist-info/METADATA +145 -0
- pyfenixcontrol-0.1.0.dist-info/RECORD +11 -0
- pyfenixcontrol-0.1.0.dist-info/WHEEL +4 -0
- pyfenixcontrol-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Asynchronous Python client for the FENIX Control cloud API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .auth import AbstractAuth, PasswordAuth
|
|
6
|
+
from .client import FenixControlClient
|
|
7
|
+
from .const import EnergyPeriod, HolidayMode, HvacState, PresetMode
|
|
8
|
+
from .exceptions import (
|
|
9
|
+
FenixControlAuthError,
|
|
10
|
+
FenixControlConnectionError,
|
|
11
|
+
FenixControlError,
|
|
12
|
+
FenixControlResponseError,
|
|
13
|
+
)
|
|
14
|
+
from .models import (
|
|
15
|
+
Device,
|
|
16
|
+
EnergyMetric,
|
|
17
|
+
Installation,
|
|
18
|
+
Room,
|
|
19
|
+
decode_temperature,
|
|
20
|
+
encode_temperature,
|
|
21
|
+
parse_holiday_date,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AbstractAuth",
|
|
28
|
+
"Device",
|
|
29
|
+
"EnergyMetric",
|
|
30
|
+
"EnergyPeriod",
|
|
31
|
+
"FenixControlAuthError",
|
|
32
|
+
"FenixControlClient",
|
|
33
|
+
"FenixControlConnectionError",
|
|
34
|
+
"FenixControlError",
|
|
35
|
+
"FenixControlResponseError",
|
|
36
|
+
"HolidayMode",
|
|
37
|
+
"HvacState",
|
|
38
|
+
"Installation",
|
|
39
|
+
"PasswordAuth",
|
|
40
|
+
"PresetMode",
|
|
41
|
+
"Room",
|
|
42
|
+
"__version__",
|
|
43
|
+
"decode_temperature",
|
|
44
|
+
"encode_temperature",
|
|
45
|
+
"parse_holiday_date",
|
|
46
|
+
]
|
pyfenixcontrol/auth.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Authentication for the FENIX Control cloud.
|
|
2
|
+
|
|
3
|
+
The client never deals with credentials directly. It asks an
|
|
4
|
+
:class:`AbstractAuth` implementation for a bearer token, so the same client
|
|
5
|
+
works with the bundled username/password flow (:class:`PasswordAuth`) or with
|
|
6
|
+
tokens managed elsewhere, e.g. Home Assistant's OAuth2 helpers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import secrets
|
|
17
|
+
import time
|
|
18
|
+
import urllib.parse
|
|
19
|
+
from abc import ABC, abstractmethod
|
|
20
|
+
from html.parser import HTMLParser
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
import aiohttp
|
|
24
|
+
|
|
25
|
+
from .const import (
|
|
26
|
+
DEFAULT_CLIENT_ID,
|
|
27
|
+
DEFAULT_REDIRECT_URI,
|
|
28
|
+
DEFAULT_REQUEST_TIMEOUT,
|
|
29
|
+
DEFAULT_SCOPES,
|
|
30
|
+
IDENTITY_BASE_URL,
|
|
31
|
+
OEM_CLIENT,
|
|
32
|
+
)
|
|
33
|
+
from .exceptions import (
|
|
34
|
+
FenixControlAuthError,
|
|
35
|
+
FenixControlConnectionError,
|
|
36
|
+
FenixControlResponseError,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from collections.abc import Mapping
|
|
41
|
+
|
|
42
|
+
_LOGGER = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
_HTTP_OK = 200
|
|
45
|
+
_HTTP_FOUND = 302
|
|
46
|
+
_HTTP_BAD_REQUEST = 400
|
|
47
|
+
_HTTP_UNAUTHORIZED = 401
|
|
48
|
+
_HTTP_SERVER_ERROR = 500
|
|
49
|
+
|
|
50
|
+
#: Refresh the access token this many seconds before it expires.
|
|
51
|
+
TOKEN_EXPIRY_MARGIN = 60.0
|
|
52
|
+
_DEFAULT_TOKEN_LIFETIME = 3600.0
|
|
53
|
+
|
|
54
|
+
_CSRF_FIELD = "__RequestVerificationToken"
|
|
55
|
+
_RETURN_URL_FIELD = "ReturnUrl"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AbstractAuth(ABC):
|
|
59
|
+
"""Supplies bearer tokens to :class:`~pyfenixcontrol.FenixControlClient`."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, session: aiohttp.ClientSession) -> None:
|
|
62
|
+
"""Initialize with the HTTP session used for all requests."""
|
|
63
|
+
self._session = session
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def session(self) -> aiohttp.ClientSession:
|
|
67
|
+
"""Return the HTTP session shared with the client."""
|
|
68
|
+
return self._session
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
async def async_get_access_token(self) -> str:
|
|
72
|
+
"""Return a valid access token, refreshing it if needed.
|
|
73
|
+
|
|
74
|
+
Raise :class:`FenixControlAuthError` if no token can be obtained
|
|
75
|
+
without user interaction.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
async def async_invalidate_access_token(self) -> None:
|
|
79
|
+
"""Mark the current access token as rejected by the API.
|
|
80
|
+
|
|
81
|
+
Called by the client after an HTTP 401. Implementations that can
|
|
82
|
+
refresh on demand should drop the cached token so the next
|
|
83
|
+
:meth:`async_get_access_token` obtains a fresh one.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def generate_pkce_pair() -> tuple[str, str]:
|
|
88
|
+
"""Return a PKCE ``(code_verifier, code_challenge)`` pair (S256)."""
|
|
89
|
+
code_verifier = secrets.token_urlsafe(96)[:128]
|
|
90
|
+
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
|
91
|
+
code_challenge = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
|
92
|
+
return code_verifier, code_challenge
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _LoginFormParser(HTMLParser):
|
|
96
|
+
"""Collect the hidden inputs of the identity server login form."""
|
|
97
|
+
|
|
98
|
+
def __init__(self) -> None:
|
|
99
|
+
super().__init__()
|
|
100
|
+
self.fields: dict[str, str] = {}
|
|
101
|
+
|
|
102
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
103
|
+
if tag != "input":
|
|
104
|
+
return
|
|
105
|
+
values = dict(attrs)
|
|
106
|
+
name = values.get("name")
|
|
107
|
+
value = values.get("value")
|
|
108
|
+
if name in (_CSRF_FIELD, _RETURN_URL_FIELD) and value:
|
|
109
|
+
self.fields.setdefault(name, value)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_login_form(html: str) -> tuple[str, str] | None:
|
|
113
|
+
"""Return ``(csrf_token, return_url)`` from the login page, if present."""
|
|
114
|
+
parser = _LoginFormParser()
|
|
115
|
+
parser.feed(html)
|
|
116
|
+
parser.close()
|
|
117
|
+
csrf = parser.fields.get(_CSRF_FIELD)
|
|
118
|
+
return_url = parser.fields.get(_RETURN_URL_FIELD)
|
|
119
|
+
if csrf and return_url:
|
|
120
|
+
return csrf, return_url
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class PasswordAuth(AbstractAuth):
|
|
125
|
+
"""OAuth2 authorization-code + PKCE login using account e-mail and password.
|
|
126
|
+
|
|
127
|
+
This drives the identity server's login form the same way the FENIX
|
|
128
|
+
Control app does. Tokens are kept in memory and refreshed automatically;
|
|
129
|
+
if the refresh token stops working, a full login is performed again.
|
|
130
|
+
|
|
131
|
+
The identity server tracks the login form with cookies, so ``session``
|
|
132
|
+
must have a working cookie jar. Identity-server cookies are cleared before
|
|
133
|
+
every full login so a cached session can never log in a different account.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
session: aiohttp.ClientSession,
|
|
139
|
+
username: str,
|
|
140
|
+
password: str,
|
|
141
|
+
*,
|
|
142
|
+
client_id: str = DEFAULT_CLIENT_ID,
|
|
143
|
+
redirect_uri: str = DEFAULT_REDIRECT_URI,
|
|
144
|
+
scopes: str = DEFAULT_SCOPES,
|
|
145
|
+
identity_url: str = IDENTITY_BASE_URL,
|
|
146
|
+
request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Initialize the password-based authenticator."""
|
|
149
|
+
super().__init__(session)
|
|
150
|
+
self._username = username
|
|
151
|
+
self._password = password
|
|
152
|
+
self._client_id = client_id
|
|
153
|
+
self._redirect_uri = redirect_uri
|
|
154
|
+
self._redirect_scheme = urllib.parse.urlsplit(redirect_uri).scheme
|
|
155
|
+
self._scopes = scopes
|
|
156
|
+
self._identity_url = identity_url.rstrip("/")
|
|
157
|
+
self._timeout = aiohttp.ClientTimeout(total=request_timeout)
|
|
158
|
+
self._lock = asyncio.Lock()
|
|
159
|
+
self._access_token: str | None = None
|
|
160
|
+
self._refresh_token: str | None = None
|
|
161
|
+
self._expires_at = 0.0
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def token_url(self) -> str:
|
|
165
|
+
"""Return the OAuth2 token endpoint."""
|
|
166
|
+
return f"{self._identity_url}/connect/token"
|
|
167
|
+
|
|
168
|
+
async def async_get_access_token(self) -> str:
|
|
169
|
+
"""Return a valid access token, refreshing or logging in as needed."""
|
|
170
|
+
async with self._lock:
|
|
171
|
+
if self._access_token and time.monotonic() < self._expires_at:
|
|
172
|
+
return self._access_token
|
|
173
|
+
if self._refresh_token:
|
|
174
|
+
try:
|
|
175
|
+
await self._refresh()
|
|
176
|
+
except (FenixControlAuthError, FenixControlResponseError) as err:
|
|
177
|
+
_LOGGER.debug("Token refresh failed (%s), logging in again", err)
|
|
178
|
+
await self._login()
|
|
179
|
+
else:
|
|
180
|
+
await self._login()
|
|
181
|
+
if self._access_token is None: # pragma: no cover - defensive
|
|
182
|
+
msg = "No access token after login"
|
|
183
|
+
raise FenixControlAuthError(msg)
|
|
184
|
+
return self._access_token
|
|
185
|
+
|
|
186
|
+
async def async_invalidate_access_token(self) -> None:
|
|
187
|
+
"""Force the next token request to refresh."""
|
|
188
|
+
self._expires_at = 0.0
|
|
189
|
+
|
|
190
|
+
async def async_login(self) -> None:
|
|
191
|
+
"""Perform a full login now, e.g. to validate credentials.
|
|
192
|
+
|
|
193
|
+
Raises :class:`FenixControlAuthError` for invalid credentials and
|
|
194
|
+
:class:`FenixControlConnectionError` if the cloud is unreachable.
|
|
195
|
+
"""
|
|
196
|
+
async with self._lock:
|
|
197
|
+
await self._login()
|
|
198
|
+
|
|
199
|
+
# -- internals ---------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
async def _request(
|
|
202
|
+
self, method: str, url: str, **kwargs: Any
|
|
203
|
+
) -> tuple[int, Mapping[str, str], str]:
|
|
204
|
+
"""Send a request and return ``(status, headers, body)``."""
|
|
205
|
+
try:
|
|
206
|
+
async with self._session.request(
|
|
207
|
+
method, url, timeout=self._timeout, **kwargs
|
|
208
|
+
) as resp:
|
|
209
|
+
return resp.status, resp.headers, await resp.text()
|
|
210
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
211
|
+
msg = f"Error communicating with identity server: {err}"
|
|
212
|
+
raise FenixControlConnectionError(msg) from err
|
|
213
|
+
|
|
214
|
+
def _store_tokens(self, body: str, *, require_refresh: bool) -> None:
|
|
215
|
+
try:
|
|
216
|
+
tokens = json.loads(body) if body else {}
|
|
217
|
+
except ValueError as err:
|
|
218
|
+
msg = "Invalid JSON from token endpoint"
|
|
219
|
+
raise FenixControlResponseError(msg) from err
|
|
220
|
+
access = tokens.get("access_token") if isinstance(tokens, dict) else None
|
|
221
|
+
if not access:
|
|
222
|
+
msg = "Token endpoint response has no access_token"
|
|
223
|
+
raise FenixControlResponseError(msg)
|
|
224
|
+
refresh = tokens.get("refresh_token") or (
|
|
225
|
+
None if require_refresh else self._refresh_token
|
|
226
|
+
)
|
|
227
|
+
if not refresh:
|
|
228
|
+
msg = "Token endpoint response has no refresh_token"
|
|
229
|
+
raise FenixControlResponseError(msg)
|
|
230
|
+
lifetime = float(tokens.get("expires_in") or _DEFAULT_TOKEN_LIFETIME)
|
|
231
|
+
self._access_token = access
|
|
232
|
+
self._refresh_token = refresh
|
|
233
|
+
self._expires_at = time.monotonic() + max(lifetime - TOKEN_EXPIRY_MARGIN, 0)
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def _raise_for_token_status(status: int, what: str) -> None:
|
|
237
|
+
if status == _HTTP_OK:
|
|
238
|
+
return
|
|
239
|
+
msg = f"{what} failed: HTTP {status}"
|
|
240
|
+
if status in (_HTTP_BAD_REQUEST, _HTTP_UNAUTHORIZED):
|
|
241
|
+
raise FenixControlAuthError(msg)
|
|
242
|
+
raise FenixControlResponseError(msg, status)
|
|
243
|
+
|
|
244
|
+
async def _refresh(self) -> None:
|
|
245
|
+
_LOGGER.debug("Refreshing access token")
|
|
246
|
+
status, _, body = await self._request(
|
|
247
|
+
"POST",
|
|
248
|
+
self.token_url,
|
|
249
|
+
data={
|
|
250
|
+
"grant_type": "refresh_token",
|
|
251
|
+
"refresh_token": self._refresh_token,
|
|
252
|
+
"client_id": self._client_id,
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
self._raise_for_token_status(status, "Token refresh")
|
|
256
|
+
self._store_tokens(body, require_refresh=False)
|
|
257
|
+
|
|
258
|
+
def _unexpected(self, step: str, status: int) -> Exception:
|
|
259
|
+
msg = f"{step}: unexpected HTTP {status}"
|
|
260
|
+
if status >= _HTTP_SERVER_ERROR:
|
|
261
|
+
return FenixControlResponseError(msg, status)
|
|
262
|
+
return FenixControlAuthError(msg)
|
|
263
|
+
|
|
264
|
+
def _is_app_redirect(self, url: str) -> bool:
|
|
265
|
+
return urllib.parse.urlsplit(url).scheme == self._redirect_scheme
|
|
266
|
+
|
|
267
|
+
async def _login(self) -> None:
|
|
268
|
+
_LOGGER.debug("Starting OAuth2 PKCE login")
|
|
269
|
+
self._access_token = None
|
|
270
|
+
self._refresh_token = None
|
|
271
|
+
self._expires_at = 0.0
|
|
272
|
+
self._session.cookie_jar.clear_domain(
|
|
273
|
+
urllib.parse.urlsplit(self._identity_url).hostname or ""
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
code_verifier, code_challenge = generate_pkce_pair()
|
|
277
|
+
state = secrets.token_urlsafe(32)
|
|
278
|
+
params = {
|
|
279
|
+
"client_id": self._client_id,
|
|
280
|
+
"response_type": "code id_token",
|
|
281
|
+
"scope": self._scopes,
|
|
282
|
+
"redirect_uri": self._redirect_uri,
|
|
283
|
+
"nonce": secrets.token_urlsafe(32),
|
|
284
|
+
"code_challenge": code_challenge,
|
|
285
|
+
"code_challenge_method": "S256",
|
|
286
|
+
"state": state,
|
|
287
|
+
"oemclient": OEM_CLIENT,
|
|
288
|
+
}
|
|
289
|
+
authorize_url = (
|
|
290
|
+
f"{self._identity_url}/connect/authorize?{urllib.parse.urlencode(params)}"
|
|
291
|
+
)
|
|
292
|
+
status, headers, _ = await self._request(
|
|
293
|
+
"GET", authorize_url, allow_redirects=False
|
|
294
|
+
)
|
|
295
|
+
location = headers.get("Location")
|
|
296
|
+
if status != _HTTP_FOUND or not location:
|
|
297
|
+
raise self._unexpected("Authorization request", status) # noqa: EM101, TRY003
|
|
298
|
+
login_url = urllib.parse.urljoin(self._identity_url, location)
|
|
299
|
+
|
|
300
|
+
if self._is_app_redirect(login_url):
|
|
301
|
+
callback_url = login_url # identity server still had a session
|
|
302
|
+
else:
|
|
303
|
+
callback_url = await self._submit_credentials(login_url)
|
|
304
|
+
|
|
305
|
+
code = await self._read_authorization_code(callback_url, state)
|
|
306
|
+
|
|
307
|
+
status, _, body = await self._request(
|
|
308
|
+
"POST",
|
|
309
|
+
self.token_url,
|
|
310
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
311
|
+
data={
|
|
312
|
+
"grant_type": "authorization_code",
|
|
313
|
+
"code": code,
|
|
314
|
+
"redirect_uri": self._redirect_uri,
|
|
315
|
+
"code_verifier": code_verifier,
|
|
316
|
+
"client_id": self._client_id,
|
|
317
|
+
},
|
|
318
|
+
)
|
|
319
|
+
self._raise_for_token_status(status, "Token exchange")
|
|
320
|
+
self._store_tokens(body, require_refresh=True)
|
|
321
|
+
_LOGGER.debug("OAuth2 login completed")
|
|
322
|
+
|
|
323
|
+
async def _submit_credentials(self, login_url: str) -> str:
|
|
324
|
+
status, _, page = await self._request("GET", login_url)
|
|
325
|
+
if status != _HTTP_OK:
|
|
326
|
+
raise self._unexpected("Login page", status) # noqa: EM101, TRY003
|
|
327
|
+
form = parse_login_form(page)
|
|
328
|
+
if form is None:
|
|
329
|
+
msg = "Login form not found on identity server page"
|
|
330
|
+
raise FenixControlResponseError(msg, status)
|
|
331
|
+
csrf_token, return_url = form
|
|
332
|
+
|
|
333
|
+
status, headers, _ = await self._request(
|
|
334
|
+
"POST",
|
|
335
|
+
login_url,
|
|
336
|
+
data={
|
|
337
|
+
"ReturnUrl": return_url,
|
|
338
|
+
"Username": self._username,
|
|
339
|
+
"Password": self._password,
|
|
340
|
+
"button": "login",
|
|
341
|
+
_CSRF_FIELD: csrf_token,
|
|
342
|
+
},
|
|
343
|
+
allow_redirects=False,
|
|
344
|
+
)
|
|
345
|
+
location = headers.get("Location")
|
|
346
|
+
if status == _HTTP_FOUND and location:
|
|
347
|
+
return urllib.parse.urljoin(self._identity_url, location)
|
|
348
|
+
if status == _HTTP_OK:
|
|
349
|
+
# The login page is rendered again with a validation message.
|
|
350
|
+
msg = "Invalid username or password"
|
|
351
|
+
raise FenixControlAuthError(msg)
|
|
352
|
+
raise self._unexpected("Login form submission", status) # noqa: EM101, TRY003
|
|
353
|
+
|
|
354
|
+
async def _read_authorization_code(self, callback_url: str, state: str) -> str:
|
|
355
|
+
if not self._is_app_redirect(callback_url):
|
|
356
|
+
# /connect/authorize/callback answers with a redirect to the app URI.
|
|
357
|
+
_, headers, _ = await self._request(
|
|
358
|
+
"GET", callback_url, allow_redirects=False
|
|
359
|
+
)
|
|
360
|
+
callback_url = headers.get("Location", callback_url)
|
|
361
|
+
|
|
362
|
+
fragment = urllib.parse.parse_qs(urllib.parse.urlsplit(callback_url).fragment)
|
|
363
|
+
code = fragment.get("code", [None])[0]
|
|
364
|
+
id_token = fragment.get("id_token", [None])[0]
|
|
365
|
+
returned_state = fragment.get("state", [None])[0]
|
|
366
|
+
if returned_state != state:
|
|
367
|
+
msg = "OAuth2 state mismatch in authorization response"
|
|
368
|
+
raise FenixControlAuthError(msg)
|
|
369
|
+
if not code or not id_token:
|
|
370
|
+
msg = "Authorization response is missing code or id_token"
|
|
371
|
+
raise FenixControlAuthError(msg)
|
|
372
|
+
return code
|
pyfenixcontrol/client.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Asynchronous client for the FENIX Control cloud API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
import aiohttp
|
|
12
|
+
|
|
13
|
+
from .const import (
|
|
14
|
+
API_BASE_URL,
|
|
15
|
+
DEFAULT_MAX_RETRIES,
|
|
16
|
+
DEFAULT_REQUEST_TIMEOUT,
|
|
17
|
+
HOLIDAY_EPOCH_DATE,
|
|
18
|
+
IDENTITY_BASE_URL,
|
|
19
|
+
EnergyPeriod,
|
|
20
|
+
HolidayMode,
|
|
21
|
+
PresetMode,
|
|
22
|
+
)
|
|
23
|
+
from .exceptions import (
|
|
24
|
+
FenixControlAuthError,
|
|
25
|
+
FenixControlConnectionError,
|
|
26
|
+
FenixControlError,
|
|
27
|
+
FenixControlResponseError,
|
|
28
|
+
)
|
|
29
|
+
from .models import (
|
|
30
|
+
Device,
|
|
31
|
+
EnergyMetric,
|
|
32
|
+
Installation,
|
|
33
|
+
encode_temperature,
|
|
34
|
+
format_holiday_date,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from .auth import AbstractAuth
|
|
39
|
+
|
|
40
|
+
_LOGGER = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
_HTTP_OK = 200
|
|
43
|
+
_HTTP_NO_CONTENT = 204
|
|
44
|
+
_HTTP_SUCCESS_MAX = 300
|
|
45
|
+
_HTTP_UNAUTHORIZED = 401
|
|
46
|
+
_HTTP_SERVER_ERROR = 500
|
|
47
|
+
|
|
48
|
+
#: Error bodies may echo account identifiers; only log a short prefix at debug.
|
|
49
|
+
_BODY_LOG_LIMIT = 256
|
|
50
|
+
|
|
51
|
+
_CONFIG_REPLACE_PATH = "/iotmanagement/v1/devices/twin/properties/config/replace"
|
|
52
|
+
_INSTALLATION_JOB_PATH = "/iotmanagement/v1/devices/installationcontroljob"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _format_api_date(value: datetime) -> str:
|
|
56
|
+
"""Format a datetime for consumption endpoints (UTC, naive values = UTC)."""
|
|
57
|
+
utc = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
|
|
58
|
+
return utc.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class FenixControlClient:
|
|
62
|
+
"""Client for FENIX TFT WiFi thermostats via the FENIX Control cloud.
|
|
63
|
+
|
|
64
|
+
All network access goes through ``auth.session``; the client owns no
|
|
65
|
+
resources and needs no explicit close.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
auth: AbstractAuth,
|
|
71
|
+
*,
|
|
72
|
+
api_url: str = API_BASE_URL,
|
|
73
|
+
identity_url: str = IDENTITY_BASE_URL,
|
|
74
|
+
request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
75
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Initialize the client.
|
|
78
|
+
|
|
79
|
+
``max_retries`` applies to HTTP 5xx responses only, with exponential
|
|
80
|
+
backoff (1 s, 2 s, ...). Client errors are never retried; a single
|
|
81
|
+
HTTP 401 triggers one token refresh and a retry.
|
|
82
|
+
"""
|
|
83
|
+
self._auth = auth
|
|
84
|
+
self._api_url = api_url.rstrip("/")
|
|
85
|
+
self._identity_url = identity_url.rstrip("/")
|
|
86
|
+
self._timeout = aiohttp.ClientTimeout(total=request_timeout)
|
|
87
|
+
self._max_retries = max_retries
|
|
88
|
+
self._user_id: str | None = None
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def user_id(self) -> str | None:
|
|
92
|
+
"""Account subject id (``sub``), available after :meth:`get_user_id`."""
|
|
93
|
+
return self._user_id
|
|
94
|
+
|
|
95
|
+
# -- transport ---------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
async def _request(
|
|
98
|
+
self,
|
|
99
|
+
method: str,
|
|
100
|
+
url: str,
|
|
101
|
+
*,
|
|
102
|
+
json_body: dict[str, Any] | None = None,
|
|
103
|
+
empty_on_no_content: bool = False,
|
|
104
|
+
) -> Any:
|
|
105
|
+
"""Send an authenticated request and return the decoded JSON body."""
|
|
106
|
+
auth_retried = False
|
|
107
|
+
attempt = 0
|
|
108
|
+
while True:
|
|
109
|
+
token = await self._auth.async_get_access_token()
|
|
110
|
+
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
|
111
|
+
try:
|
|
112
|
+
async with self._auth.session.request(
|
|
113
|
+
method,
|
|
114
|
+
url,
|
|
115
|
+
headers=headers,
|
|
116
|
+
json=json_body,
|
|
117
|
+
timeout=self._timeout,
|
|
118
|
+
allow_redirects=False,
|
|
119
|
+
) as resp:
|
|
120
|
+
status = resp.status
|
|
121
|
+
body = await resp.text()
|
|
122
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
123
|
+
msg = f"Error communicating with FENIX cloud: {err}"
|
|
124
|
+
raise FenixControlConnectionError(msg) from err
|
|
125
|
+
|
|
126
|
+
if _HTTP_OK <= status < _HTTP_SUCCESS_MAX:
|
|
127
|
+
if status == _HTTP_NO_CONTENT or not body:
|
|
128
|
+
return [] if empty_on_no_content else None
|
|
129
|
+
try:
|
|
130
|
+
return json.loads(body)
|
|
131
|
+
except ValueError as err:
|
|
132
|
+
msg = f"{method} {url}: invalid JSON response"
|
|
133
|
+
raise FenixControlResponseError(msg, status) from err
|
|
134
|
+
|
|
135
|
+
_LOGGER.debug(
|
|
136
|
+
"%s %s -> HTTP %s: %s", method, url, status, body[:_BODY_LOG_LIMIT]
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if status == _HTTP_UNAUTHORIZED:
|
|
140
|
+
if auth_retried:
|
|
141
|
+
msg = f"{method} {url}: HTTP 401 with a fresh token"
|
|
142
|
+
raise FenixControlAuthError(msg)
|
|
143
|
+
auth_retried = True
|
|
144
|
+
await self._auth.async_invalidate_access_token()
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
if status >= _HTTP_SERVER_ERROR and attempt < self._max_retries:
|
|
148
|
+
delay = 2**attempt
|
|
149
|
+
attempt += 1
|
|
150
|
+
_LOGGER.debug(
|
|
151
|
+
"HTTP %s, retry %d/%d in %ds",
|
|
152
|
+
status,
|
|
153
|
+
attempt,
|
|
154
|
+
self._max_retries,
|
|
155
|
+
delay,
|
|
156
|
+
)
|
|
157
|
+
await asyncio.sleep(delay)
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
msg = f"{method} {url}: HTTP {status}"
|
|
161
|
+
raise FenixControlResponseError(msg, status)
|
|
162
|
+
|
|
163
|
+
async def _get(self, path: str, *, empty_on_no_content: bool = False) -> Any:
|
|
164
|
+
return await self._request(
|
|
165
|
+
"GET", f"{self._api_url}{path}", empty_on_no_content=empty_on_no_content
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
async def _put(self, path: str, body: dict[str, Any]) -> Any:
|
|
169
|
+
return await self._request("PUT", f"{self._api_url}{path}", json_body=body)
|
|
170
|
+
|
|
171
|
+
async def _require_user_id(self) -> str:
|
|
172
|
+
return self._user_id or await self.get_user_id()
|
|
173
|
+
|
|
174
|
+
# -- account & topology ------------------------------------------------
|
|
175
|
+
|
|
176
|
+
async def get_user_id(self) -> str:
|
|
177
|
+
"""Fetch and cache the account subject id from OIDC ``userinfo``."""
|
|
178
|
+
data = await self._request("GET", f"{self._identity_url}/connect/userinfo")
|
|
179
|
+
sub = data.get("sub") if isinstance(data, dict) else None
|
|
180
|
+
if not sub:
|
|
181
|
+
msg = "userinfo response has no 'sub'"
|
|
182
|
+
raise FenixControlResponseError(msg)
|
|
183
|
+
self._user_id = str(sub)
|
|
184
|
+
return self._user_id
|
|
185
|
+
|
|
186
|
+
async def get_installations(self) -> list[Installation]:
|
|
187
|
+
"""Return the installations (homes) the account administers."""
|
|
188
|
+
user_id = await self._require_user_id()
|
|
189
|
+
data = await self._get(
|
|
190
|
+
f"/businessmodule/v1/installations/admins/{user_id}",
|
|
191
|
+
empty_on_no_content=True,
|
|
192
|
+
)
|
|
193
|
+
if not isinstance(data, list):
|
|
194
|
+
msg = "installations response is not a list"
|
|
195
|
+
raise FenixControlResponseError(msg)
|
|
196
|
+
return [Installation.from_api(i) for i in data if isinstance(i, dict)]
|
|
197
|
+
|
|
198
|
+
async def get_device_properties(self, device_id: str) -> dict[str, Any]:
|
|
199
|
+
"""Return the raw device twin properties of one thermostat."""
|
|
200
|
+
data = await self._get(
|
|
201
|
+
f"/iotmanagement/v1/configuration/{device_id}/{device_id}/v1/content/"
|
|
202
|
+
)
|
|
203
|
+
if not isinstance(data, dict):
|
|
204
|
+
msg = f"properties of device {device_id} are not an object"
|
|
205
|
+
raise FenixControlResponseError(msg)
|
|
206
|
+
return data
|
|
207
|
+
|
|
208
|
+
async def get_devices(
|
|
209
|
+
self, installations: list[Installation] | None = None
|
|
210
|
+
) -> list[Device]:
|
|
211
|
+
"""Return the state of every thermostat.
|
|
212
|
+
|
|
213
|
+
A device whose properties cannot be fetched is skipped (and logged);
|
|
214
|
+
authentication and connection errors are raised, since they affect
|
|
215
|
+
the whole account.
|
|
216
|
+
"""
|
|
217
|
+
if installations is None:
|
|
218
|
+
installations = await self.get_installations()
|
|
219
|
+
devices: list[Device] = []
|
|
220
|
+
for installation in installations:
|
|
221
|
+
for room in installation.rooms:
|
|
222
|
+
for device_id in room.device_ids:
|
|
223
|
+
try:
|
|
224
|
+
props = await self.get_device_properties(device_id)
|
|
225
|
+
except (FenixControlAuthError, FenixControlConnectionError):
|
|
226
|
+
raise
|
|
227
|
+
except FenixControlError as err:
|
|
228
|
+
_LOGGER.warning("Skipping device %s: %s", device_id, err)
|
|
229
|
+
continue
|
|
230
|
+
devices.append(
|
|
231
|
+
Device.from_api(device_id, props, installation, room)
|
|
232
|
+
)
|
|
233
|
+
return devices
|
|
234
|
+
|
|
235
|
+
async def trigger_installation_update(self, installation_id: str) -> None:
|
|
236
|
+
"""Ask the thermostats of an installation to report fresh values.
|
|
237
|
+
|
|
238
|
+
This is what the app sends when it is opened ("user connected").
|
|
239
|
+
"""
|
|
240
|
+
await self._put(
|
|
241
|
+
"/iotmanagement/v1/devices/userconnected",
|
|
242
|
+
{"A1": await self._require_user_id(), "In": installation_id},
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# -- control -----------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
async def set_target_temperature(self, device_id: str, celsius: float) -> None:
|
|
248
|
+
"""Set the target temperature (°C); switches the device to manual."""
|
|
249
|
+
await self._put(
|
|
250
|
+
_CONFIG_REPLACE_PATH,
|
|
251
|
+
{
|
|
252
|
+
"Id_deviceId": device_id,
|
|
253
|
+
"S1": device_id,
|
|
254
|
+
"configurationVersion": "v1.0",
|
|
255
|
+
"data": [
|
|
256
|
+
{"wattsType": "Dm", "wattsTypeValue": int(PresetMode.MANUAL)},
|
|
257
|
+
{"wattsType": "Ma", "wattsTypeValue": encode_temperature(celsius)},
|
|
258
|
+
],
|
|
259
|
+
},
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
async def set_preset_mode(self, device_id: str, mode: PresetMode | int) -> None:
|
|
263
|
+
"""Set the operating mode of a thermostat."""
|
|
264
|
+
try:
|
|
265
|
+
mode = PresetMode(mode)
|
|
266
|
+
except ValueError as err:
|
|
267
|
+
msg = f"Invalid preset mode: {mode}"
|
|
268
|
+
raise ValueError(msg) from err
|
|
269
|
+
await self._put(
|
|
270
|
+
_CONFIG_REPLACE_PATH,
|
|
271
|
+
{
|
|
272
|
+
"Id_deviceId": device_id,
|
|
273
|
+
"S1": device_id,
|
|
274
|
+
"configurationVersion": "v1.0",
|
|
275
|
+
"data": [{"wattsType": "Dm", "wattsTypeValue": int(mode)}],
|
|
276
|
+
},
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
async def set_holiday_schedule(
|
|
280
|
+
self,
|
|
281
|
+
installation_id: str,
|
|
282
|
+
start: datetime,
|
|
283
|
+
end: datetime,
|
|
284
|
+
mode: HolidayMode | int,
|
|
285
|
+
) -> None:
|
|
286
|
+
"""Schedule a holiday for a whole installation.
|
|
287
|
+
|
|
288
|
+
``start``/``end`` are written as wall-clock time of the installation;
|
|
289
|
+
pass naive or local datetimes, they are not converted.
|
|
290
|
+
"""
|
|
291
|
+
mode = HolidayMode(mode)
|
|
292
|
+
if mode is HolidayMode.NONE:
|
|
293
|
+
msg = "Use cancel_holiday_schedule() to clear a holiday"
|
|
294
|
+
raise ValueError(msg)
|
|
295
|
+
if end <= start:
|
|
296
|
+
msg = "Holiday end must be after start"
|
|
297
|
+
raise ValueError(msg)
|
|
298
|
+
await self._put_holiday(
|
|
299
|
+
installation_id,
|
|
300
|
+
format_holiday_date(start),
|
|
301
|
+
format_holiday_date(end),
|
|
302
|
+
int(mode),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
async def cancel_holiday_schedule(self, installation_id: str) -> None:
|
|
306
|
+
"""Remove the holiday schedule of an installation."""
|
|
307
|
+
await self._put_holiday(
|
|
308
|
+
installation_id, HOLIDAY_EPOCH_DATE, HOLIDAY_EPOCH_DATE, 0
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
async def _put_holiday(
|
|
312
|
+
self, installation_id: str, start: str, end: str, mode: int
|
|
313
|
+
) -> None:
|
|
314
|
+
await self._put(
|
|
315
|
+
_INSTALLATION_JOB_PATH,
|
|
316
|
+
{
|
|
317
|
+
"In": installation_id,
|
|
318
|
+
"A1": await self._require_user_id(),
|
|
319
|
+
"data": [
|
|
320
|
+
{"timestamp": None, "wattsType": "H1", "wattsTypeValue": start},
|
|
321
|
+
{"timestamp": None, "wattsType": "H2", "wattsTypeValue": end},
|
|
322
|
+
{
|
|
323
|
+
"timestamp": None,
|
|
324
|
+
"wattsType": "H3",
|
|
325
|
+
"wattsTypeValue": [mode, 0, 0],
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
},
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
# -- energy ------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
async def get_room_energy(
|
|
334
|
+
self,
|
|
335
|
+
installation_id: str,
|
|
336
|
+
room_id: str,
|
|
337
|
+
start: datetime,
|
|
338
|
+
end: datetime,
|
|
339
|
+
period: EnergyPeriod | str = EnergyPeriod.HOUR,
|
|
340
|
+
) -> list[EnergyMetric]:
|
|
341
|
+
"""Return energy consumption (Wh) of a room between ``start`` and ``end``.
|
|
342
|
+
|
|
343
|
+
Datetimes are converted to UTC; naive values are taken as UTC.
|
|
344
|
+
"""
|
|
345
|
+
user_id = await self._require_user_id()
|
|
346
|
+
period = EnergyPeriod(period)
|
|
347
|
+
data = await self._get(
|
|
348
|
+
"/DataProcessing/v1/metricsAggregat/consommation/room/"
|
|
349
|
+
f"{installation_id}/{room_id}/{user_id}/{period.value}/Wc/"
|
|
350
|
+
f"{_format_api_date(start)}/{_format_api_date(end)}",
|
|
351
|
+
empty_on_no_content=True,
|
|
352
|
+
)
|
|
353
|
+
if not isinstance(data, list):
|
|
354
|
+
msg = "energy response is not a list"
|
|
355
|
+
raise FenixControlResponseError(msg)
|
|
356
|
+
return [EnergyMetric.from_api(item) for item in data if isinstance(item, dict)]
|
pyfenixcontrol/const.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Constants and enumerations for the FENIX Control cloud API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import IntEnum, StrEnum
|
|
6
|
+
from typing import Final
|
|
7
|
+
|
|
8
|
+
API_BASE_URL: Final = "https://vs2-fe-apim-prod.azure-api.net"
|
|
9
|
+
IDENTITY_BASE_URL: Final = "https://vs2-fe-identity-prod.azurewebsites.net"
|
|
10
|
+
|
|
11
|
+
# Public OAuth2 client of the FENIX Control mobile app (PKCE, no secret).
|
|
12
|
+
# Pass your own ``client_id`` to :class:`~pyfenixcontrol.PasswordAuth` once a
|
|
13
|
+
# dedicated client is issued.
|
|
14
|
+
DEFAULT_CLIENT_ID: Final = "b1760b2e-69f1-4e89-8233-5840a9accdf8"
|
|
15
|
+
DEFAULT_REDIRECT_URI: Final = "fenix://callback"
|
|
16
|
+
OEM_CLIENT: Final = "fenix"
|
|
17
|
+
DEFAULT_SCOPES: Final = (
|
|
18
|
+
"profile openid offline_access "
|
|
19
|
+
"DataProcessing.Read DataProcessing.Write "
|
|
20
|
+
"Device.Read Device.Write "
|
|
21
|
+
"Installation.Read Installation.Write "
|
|
22
|
+
"IOTManagement.Read IOTManagement.Write "
|
|
23
|
+
"Room.Read Room.Write "
|
|
24
|
+
"TermOfUse.Read TermOfUse.Write"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
DEFAULT_REQUEST_TIMEOUT: Final = 10.0
|
|
28
|
+
DEFAULT_MAX_RETRIES: Final = 2
|
|
29
|
+
|
|
30
|
+
# Sentinel the backend uses in H1/H2 for "no holiday schedule". Not a valid date.
|
|
31
|
+
HOLIDAY_EPOCH_DATE: Final = "00/00/1970 00:00:00"
|
|
32
|
+
HOLIDAY_DATE_FORMAT: Final = "%d/%m/%Y %H:%M:%S"
|
|
33
|
+
|
|
34
|
+
#: Divisor applied to raw temperature values when none is given by the API.
|
|
35
|
+
DEFAULT_DIV_FACTOR: Final = 10
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PresetMode(IntEnum):
|
|
39
|
+
"""Operating mode of a thermostat (``Cm`` read / ``Dm`` write)."""
|
|
40
|
+
|
|
41
|
+
OFF = 0
|
|
42
|
+
HOLIDAYS = 1
|
|
43
|
+
PROGRAM = 2
|
|
44
|
+
DEFROST = 4
|
|
45
|
+
BOOST = 5
|
|
46
|
+
MANUAL = 6
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class HvacState(IntEnum):
|
|
50
|
+
"""Heating state reported in ``Hs`` outside holiday mode."""
|
|
51
|
+
|
|
52
|
+
IDLE = 0
|
|
53
|
+
HEATING = 1
|
|
54
|
+
OFF = 2
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class HolidayMode(IntEnum):
|
|
58
|
+
"""Holiday behaviour of an installation (``H3[0]`` / ``H4``)."""
|
|
59
|
+
|
|
60
|
+
NONE = 0
|
|
61
|
+
OFF = 1
|
|
62
|
+
REDUCE = 2
|
|
63
|
+
DEFROST = 5
|
|
64
|
+
SUNDAY = 8
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class EnergyPeriod(StrEnum):
|
|
68
|
+
"""Aggregation period of the energy consumption endpoint."""
|
|
69
|
+
|
|
70
|
+
HOUR = "Hour"
|
|
71
|
+
DAY = "Day"
|
|
72
|
+
WEEK = "Week"
|
|
73
|
+
MONTH = "Month"
|
|
74
|
+
QUARTER = "Quarter"
|
|
75
|
+
YEAR = "Year"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Exceptions raised by pyfenixcontrol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FenixControlError(Exception):
|
|
7
|
+
"""Base class for all pyfenixcontrol errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FenixControlConnectionError(FenixControlError):
|
|
11
|
+
"""The cloud could not be reached (network error, timeout, DNS)."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FenixControlAuthError(FenixControlError):
|
|
15
|
+
"""Authentication failed or the credentials/tokens are no longer valid.
|
|
16
|
+
|
|
17
|
+
Callers should treat this as "user action required" (e.g. a reauth flow).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FenixControlResponseError(FenixControlError):
|
|
22
|
+
"""The cloud returned an unexpected status or an unparsable body."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, message: str, status: int | None = None) -> None:
|
|
25
|
+
"""Initialize with an optional HTTP status code."""
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.status = status
|
pyfenixcontrol/models.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Data models returned by :class:`~pyfenixcontrol.FenixControlClient`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from .const import DEFAULT_DIV_FACTOR, HOLIDAY_DATE_FORMAT, HOLIDAY_EPOCH_DATE
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def decode_temperature(entry: Mapping[str, Any] | None) -> float | None:
|
|
16
|
+
"""Decode a ``{"value", "divFactor"}`` entry (°F x divFactor) to °C."""
|
|
17
|
+
if not entry:
|
|
18
|
+
return None
|
|
19
|
+
value = entry.get("value")
|
|
20
|
+
if not isinstance(value, int | float) or isinstance(value, bool):
|
|
21
|
+
return None
|
|
22
|
+
div = entry.get("divFactor") or 1
|
|
23
|
+
fahrenheit = value / div
|
|
24
|
+
return (fahrenheit - 32.0) * 5.0 / 9.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def encode_temperature(celsius: float, div_factor: int = DEFAULT_DIV_FACTOR) -> int:
|
|
28
|
+
"""Encode °C to the raw API value (°F x divFactor, rounded)."""
|
|
29
|
+
return round((celsius * 9.0 / 5.0 + 32.0) * div_factor)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_holiday_date(value: str | None) -> datetime | None:
|
|
33
|
+
"""Parse a holiday ``H1``/``H2`` string to a naive local datetime.
|
|
34
|
+
|
|
35
|
+
The backend stores holiday dates in the installation's local time without
|
|
36
|
+
an offset. Returns ``None`` for the "no holiday" sentinel or invalid input.
|
|
37
|
+
"""
|
|
38
|
+
if not value or value == HOLIDAY_EPOCH_DATE:
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
return datetime.strptime(value, HOLIDAY_DATE_FORMAT) # noqa: DTZ007
|
|
42
|
+
except (TypeError, ValueError):
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def format_holiday_date(value: datetime) -> str:
|
|
47
|
+
"""Format a datetime for the holiday ``H1``/``H2`` fields."""
|
|
48
|
+
return value.strftime(HOLIDAY_DATE_FORMAT)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _prop(props: Mapping[str, Any], key: str) -> Any:
|
|
52
|
+
entry = props.get(key)
|
|
53
|
+
if isinstance(entry, dict):
|
|
54
|
+
return entry.get("value")
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _as_int(value: Any) -> int | None:
|
|
59
|
+
if isinstance(value, bool):
|
|
60
|
+
return None
|
|
61
|
+
if isinstance(value, int):
|
|
62
|
+
return value
|
|
63
|
+
if isinstance(value, float) and value.is_integer():
|
|
64
|
+
return int(value)
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _as_str(value: Any) -> str | None:
|
|
69
|
+
return value if isinstance(value, str) else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class Room:
|
|
74
|
+
"""A room (``Zn``) of an installation and the thermostats in it."""
|
|
75
|
+
|
|
76
|
+
id: str
|
|
77
|
+
name: str | None
|
|
78
|
+
device_ids: tuple[str, ...]
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_api(cls, data: Mapping[str, Any]) -> Room:
|
|
82
|
+
"""Build from an entry of an installation's ``rooms`` list."""
|
|
83
|
+
devices = data.get("devices") or []
|
|
84
|
+
return cls(
|
|
85
|
+
id=str(data.get("Zn", "")),
|
|
86
|
+
name=_as_str(data.get("Rn")),
|
|
87
|
+
device_ids=tuple(
|
|
88
|
+
str(dev["Id_deviceId"])
|
|
89
|
+
for dev in devices
|
|
90
|
+
if isinstance(dev, dict) and dev.get("Id_deviceId")
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class Installation:
|
|
97
|
+
"""An installation (home) the account administers."""
|
|
98
|
+
|
|
99
|
+
id: str
|
|
100
|
+
name: str | None
|
|
101
|
+
rooms: tuple[Room, ...]
|
|
102
|
+
raw: Mapping[str, Any] = field(repr=False, compare=False)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_api(cls, data: Mapping[str, Any]) -> Installation:
|
|
106
|
+
"""Build from an entry of ``/installations/admins/{sub}``."""
|
|
107
|
+
rooms = data.get("rooms") or []
|
|
108
|
+
return cls(
|
|
109
|
+
id=str(data.get("id", "")),
|
|
110
|
+
name=_as_str(data.get("Il")),
|
|
111
|
+
rooms=tuple(Room.from_api(r) for r in rooms if isinstance(r, dict)),
|
|
112
|
+
raw=data,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True, slots=True)
|
|
117
|
+
class Device:
|
|
118
|
+
"""Current state of one thermostat.
|
|
119
|
+
|
|
120
|
+
Field reference (property keys of the device twin):
|
|
121
|
+
|
|
122
|
+
* ``Cm`` - preset mode, see :class:`~pyfenixcontrol.PresetMode`
|
|
123
|
+
* ``Hs`` - heating state (:class:`~pyfenixcontrol.HvacState`); while in
|
|
124
|
+
holiday mode it carries the holiday mode instead
|
|
125
|
+
* ``H1``/``H2`` - holiday start/end, ``H3[0]`` - scheduled holiday mode,
|
|
126
|
+
``H4`` - currently active holiday mode (primary holiday indicator)
|
|
127
|
+
* ``Ma`` - target, ``At`` - room, ``bo`` - floor, ``Sp`` - holiday target
|
|
128
|
+
temperature
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
id: str
|
|
132
|
+
name: str | None
|
|
133
|
+
installation_id: str
|
|
134
|
+
installation_name: str | None
|
|
135
|
+
room_id: str
|
|
136
|
+
software_version: str | None
|
|
137
|
+
model: str | None
|
|
138
|
+
target_temperature: float | None
|
|
139
|
+
room_temperature: float | None
|
|
140
|
+
floor_temperature: float | None
|
|
141
|
+
holiday_target_temperature: float | None
|
|
142
|
+
preset_mode: int | None
|
|
143
|
+
hvac_state: int | None
|
|
144
|
+
holiday_start: str | None
|
|
145
|
+
holiday_end: str | None
|
|
146
|
+
holiday_mode: int
|
|
147
|
+
active_holiday_mode: int
|
|
148
|
+
raw: Mapping[str, Any] = field(repr=False, compare=False)
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def from_api(
|
|
152
|
+
cls,
|
|
153
|
+
device_id: str,
|
|
154
|
+
props: Mapping[str, Any],
|
|
155
|
+
installation: Installation,
|
|
156
|
+
room: Room,
|
|
157
|
+
) -> Device:
|
|
158
|
+
"""Build from the device twin ``content`` properties."""
|
|
159
|
+
h3 = _prop(props, "H3")
|
|
160
|
+
holiday_mode = _as_int(h3[0]) if isinstance(h3, list) and h3 else None
|
|
161
|
+
name = _prop(props, "Rn")
|
|
162
|
+
return cls(
|
|
163
|
+
id=device_id,
|
|
164
|
+
name=_as_str(name),
|
|
165
|
+
installation_id=installation.id,
|
|
166
|
+
installation_name=installation.name,
|
|
167
|
+
room_id=room.id,
|
|
168
|
+
software_version=_as_str(_prop(props, "Sv")),
|
|
169
|
+
model=None if _prop(props, "Ty") is None else str(_prop(props, "Ty")),
|
|
170
|
+
target_temperature=decode_temperature(props.get("Ma")),
|
|
171
|
+
room_temperature=decode_temperature(props.get("At")),
|
|
172
|
+
floor_temperature=decode_temperature(props.get("bo")),
|
|
173
|
+
holiday_target_temperature=decode_temperature(props.get("Sp")),
|
|
174
|
+
preset_mode=_as_int(_prop(props, "Cm")),
|
|
175
|
+
hvac_state=_as_int(_prop(props, "Hs")),
|
|
176
|
+
holiday_start=_as_str(_prop(props, "H1")),
|
|
177
|
+
holiday_end=_as_str(_prop(props, "H2")),
|
|
178
|
+
holiday_mode=holiday_mode or 0,
|
|
179
|
+
active_holiday_mode=_as_int(_prop(props, "H4")) or 0,
|
|
180
|
+
raw=props,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def holiday_end_date(self) -> datetime | None:
|
|
185
|
+
"""Holiday end as naive local datetime, ``None`` if not scheduled."""
|
|
186
|
+
return parse_holiday_date(self.holiday_end)
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def holiday_active(self) -> bool:
|
|
190
|
+
"""Return True while a holiday mode is in effect (``H4`` != 0)."""
|
|
191
|
+
return self.active_holiday_mode != 0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True, slots=True)
|
|
195
|
+
class EnergyMetric:
|
|
196
|
+
"""One aggregated energy consumption bucket of a room (Wh)."""
|
|
197
|
+
|
|
198
|
+
start: datetime | None
|
|
199
|
+
value: float
|
|
200
|
+
processed_value: float
|
|
201
|
+
raw: Mapping[str, Any] = field(repr=False, compare=False)
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def from_api(cls, data: Mapping[str, Any]) -> EnergyMetric:
|
|
205
|
+
"""Build from an item of the ``metricsAggregat/consommation`` endpoint."""
|
|
206
|
+
start: datetime | None = None
|
|
207
|
+
start_raw = data.get("startDateOfMetric")
|
|
208
|
+
if isinstance(start_raw, str):
|
|
209
|
+
try:
|
|
210
|
+
start = datetime.fromisoformat(start_raw)
|
|
211
|
+
except ValueError:
|
|
212
|
+
start = None
|
|
213
|
+
else:
|
|
214
|
+
start = (
|
|
215
|
+
start.replace(tzinfo=UTC)
|
|
216
|
+
if start.tzinfo is None
|
|
217
|
+
else start.astimezone(UTC)
|
|
218
|
+
)
|
|
219
|
+
return cls(
|
|
220
|
+
start=start,
|
|
221
|
+
value=_as_float(data.get("sum")),
|
|
222
|
+
processed_value=_as_float(data.get("processedDataWithAggregator")),
|
|
223
|
+
raw=data,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _as_float(value: Any) -> float:
|
|
228
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
229
|
+
return 0.0
|
|
230
|
+
return float(value)
|
pyfenixcontrol/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pyfenixcontrol
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Asynchronous Python client for the FENIX Control cloud API (FENIX TFT WiFi thermostats).
|
|
5
|
+
Project-URL: Homepage, https://github.com/baracudaz/pyfenixcontrol
|
|
6
|
+
Project-URL: Source, https://github.com/baracudaz/pyfenixcontrol
|
|
7
|
+
Project-URL: Issues, https://github.com/baracudaz/pyfenixcontrol/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/baracudaz/pyfenixcontrol/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Stanislav Likavčan <likavcan@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: asyncio,fenix,fenix control,home assistant,tft,thermostat
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Home Automation
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# pyfenixcontrol
|
|
28
|
+
|
|
29
|
+
Asynchronous Python client for the **FENIX Control** cloud, used by FENIX TFT WiFi
|
|
30
|
+
thermostats. It powers the FENIX integration for [Home Assistant](https://www.home-assistant.io/),
|
|
31
|
+
but has no Home Assistant dependency and can be used from any asyncio code.
|
|
32
|
+
|
|
33
|
+
- Fully typed (`py.typed`), `asyncio` + `aiohttp`, Python 3.12+
|
|
34
|
+
- Read thermostats, rooms and installations; set target temperature and operating mode
|
|
35
|
+
- Installation-wide holiday schedules
|
|
36
|
+
- Room energy consumption at hourly … yearly aggregation
|
|
37
|
+
- Pluggable authentication: bundled e-mail/password login, or bring your own OAuth2 tokens
|
|
38
|
+
- Automatic token refresh, single retry on HTTP 401, backoff on HTTP 5xx
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install pyfenixcontrol
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import asyncio
|
|
50
|
+
|
|
51
|
+
import aiohttp
|
|
52
|
+
|
|
53
|
+
from pyfenixcontrol import FenixControlClient, PasswordAuth, PresetMode
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def main() -> None:
|
|
57
|
+
async with aiohttp.ClientSession() as session:
|
|
58
|
+
auth = PasswordAuth(session, "you@example.com", "password")
|
|
59
|
+
client = FenixControlClient(auth)
|
|
60
|
+
|
|
61
|
+
for device in await client.get_devices():
|
|
62
|
+
print(device.name, device.room_temperature, device.target_temperature)
|
|
63
|
+
|
|
64
|
+
await client.set_target_temperature(device.id, 21.5)
|
|
65
|
+
await client.set_preset_mode(device.id, PresetMode.PROGRAM)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
asyncio.run(main())
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Authentication
|
|
72
|
+
|
|
73
|
+
The client never sees credentials. It asks an `AbstractAuth` for a bearer token:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from pyfenixcontrol import AbstractAuth
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class MyAuth(AbstractAuth):
|
|
80
|
+
async def async_get_access_token(self) -> str:
|
|
81
|
+
return await my_token_store.get_valid_token()
|
|
82
|
+
|
|
83
|
+
async def async_invalidate_access_token(self) -> None:
|
|
84
|
+
# Called after an HTTP 401; optional.
|
|
85
|
+
await my_token_store.mark_expired()
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`PasswordAuth` performs the OAuth2 authorization-code + PKCE login used by the
|
|
89
|
+
FENIX Control app and keeps tokens in memory. `await auth.async_login()` validates
|
|
90
|
+
credentials up front (e.g. in a setup wizard).
|
|
91
|
+
|
|
92
|
+
`PasswordAuth` needs an `aiohttp` session with a working cookie jar, because the
|
|
93
|
+
identity server tracks its login form with cookies. Cookies of the identity host
|
|
94
|
+
are cleared before each full login.
|
|
95
|
+
|
|
96
|
+
## API overview
|
|
97
|
+
|
|
98
|
+
| Method | Description |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `get_user_id()` | Account subject id (cached on the client) |
|
|
101
|
+
| `get_installations()` | Installations (homes) with their rooms and device ids |
|
|
102
|
+
| `get_devices(installations=None)` | `Device` state of every thermostat |
|
|
103
|
+
| `get_device_properties(device_id)` | Raw device twin properties |
|
|
104
|
+
| `trigger_installation_update(installation_id)` | Ask thermostats to report fresh values |
|
|
105
|
+
| `set_target_temperature(device_id, celsius)` | Set target °C (switches to manual) |
|
|
106
|
+
| `set_preset_mode(device_id, mode)` | `PresetMode.OFF / PROGRAM / MANUAL / DEFROST / BOOST / HOLIDAYS` |
|
|
107
|
+
| `set_holiday_schedule(installation_id, start, end, mode)` | Holiday for the whole installation |
|
|
108
|
+
| `cancel_holiday_schedule(installation_id)` | Remove the holiday schedule |
|
|
109
|
+
| `get_room_energy(installation_id, room_id, start, end, period)` | `EnergyMetric` buckets in Wh |
|
|
110
|
+
|
|
111
|
+
### Errors
|
|
112
|
+
|
|
113
|
+
| Exception | Meaning |
|
|
114
|
+
|---|---|
|
|
115
|
+
| `FenixControlAuthError` | Credentials or tokens rejected – user action needed |
|
|
116
|
+
| `FenixControlConnectionError` | Network error or timeout – retry later |
|
|
117
|
+
| `FenixControlResponseError` | Unexpected status or body (`.status` holds the HTTP code) |
|
|
118
|
+
|
|
119
|
+
All inherit from `FenixControlError`.
|
|
120
|
+
|
|
121
|
+
### Units and conventions
|
|
122
|
+
|
|
123
|
+
- Temperatures are °C. The API transports °F × `divFactor`;
|
|
124
|
+
`decode_temperature()` / `encode_temperature()` convert.
|
|
125
|
+
- Holiday `start`/`end` are wall-clock times of the installation (no time zone
|
|
126
|
+
conversion). Energy queries convert to UTC; naive datetimes are taken as UTC.
|
|
127
|
+
- Every model keeps the original payload in `.raw` for fields not yet modelled.
|
|
128
|
+
|
|
129
|
+
## Development
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
uv sync
|
|
133
|
+
uv run pytest --cov
|
|
134
|
+
uv run ruff check . && uv run ruff format --check . && uv run mypy
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Tests run against a local fake of the cloud and identity server; no account is needed.
|
|
138
|
+
|
|
139
|
+
Releases: bump `__version__`, update `CHANGELOG.md`, publish a GitHub release
|
|
140
|
+
tagged `vX.Y.Z`. The release workflow publishes to PyPI via trusted publishing.
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT – see [LICENSE](LICENSE). Not an official product of FENIX Trading s.r.o.
|
|
145
|
+
unless stated otherwise in the project metadata.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pyfenixcontrol/__init__.py,sha256=0AL_EG4g_h6KByDtlGe5orpC0NJAIMW2T8cXCslhu4s,1014
|
|
2
|
+
pyfenixcontrol/auth.py,sha256=qaay2tA5Lwk81765HBy2I5bOu1lujxZ151m0rvTj-yk,14045
|
|
3
|
+
pyfenixcontrol/client.py,sha256=rYIPoopYoUWpFWkFzh-ZzJp7QVD0z2_BG1cYCrJFbBI,12931
|
|
4
|
+
pyfenixcontrol/const.py,sha256=7IAx6SweSuB42xyEtREwpvRUq5br8GsYVpNMUBxpGYA,1996
|
|
5
|
+
pyfenixcontrol/exceptions.py,sha256=Cin8gvEHMtUYGNnupJBe-ZwjcKdJfW77IVFFaq6DW0A,834
|
|
6
|
+
pyfenixcontrol/models.py,sha256=p5MtJO--i1XcpcZMIfHK1_q-ntXTodOeBqoaqEU32QM,7627
|
|
7
|
+
pyfenixcontrol/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
pyfenixcontrol-0.1.0.dist-info/METADATA,sha256=SENxfEH7WXfjy_L7SmIsVtqUkRNvJVlZxiIzg_TnHHM,5497
|
|
9
|
+
pyfenixcontrol-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
pyfenixcontrol-0.1.0.dist-info/licenses/LICENSE,sha256=pMqdasdX58PScxBDpEviSCkbDtikyx-JCqBzqutD95k,1081
|
|
11
|
+
pyfenixcontrol-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 Stanislav Likavčan
|
|
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.
|