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