dimplex-controller 0.8.0__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dimplex-controller
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Python client for Dimplex heating controllers (GDHV IoT)
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -2,7 +2,16 @@
2
2
 
3
3
  from .auth import TokenBundle
4
4
  from .client import DimplexControl
5
- from .exceptions import DimplexApiError, DimplexAuthError, DimplexConnectionError, DimplexError
5
+ from .exceptions import (
6
+ DimplexApiError,
7
+ DimplexAuthError,
8
+ DimplexAuthInvalidCredentialsError,
9
+ DimplexAuthInvalidGrantError,
10
+ DimplexAuthParseError,
11
+ DimplexAuthTransientError,
12
+ DimplexConnectionError,
13
+ DimplexError,
14
+ )
6
15
  from .models import (
7
16
  Appliance,
8
17
  ApplianceModeFlag,
@@ -48,5 +57,9 @@ __all__ = [
48
57
  "DimplexError",
49
58
  "DimplexApiError",
50
59
  "DimplexAuthError",
60
+ "DimplexAuthInvalidGrantError",
61
+ "DimplexAuthInvalidCredentialsError",
62
+ "DimplexAuthParseError",
63
+ "DimplexAuthTransientError",
51
64
  "DimplexConnectionError",
52
65
  ]
@@ -0,0 +1,476 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import time
9
+ from collections.abc import Awaitable, Callable
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+ from urllib.parse import parse_qs, urlencode, urlparse
13
+
14
+ import aiohttp
15
+
16
+ from .const import (
17
+ AUTH_URL,
18
+ B2C_POLICY,
19
+ CLIENT_ID,
20
+ HTTP_OK,
21
+ REDIRECT_URI,
22
+ SCOPE,
23
+ )
24
+ from .exceptions import (
25
+ DimplexAuthError,
26
+ DimplexAuthInvalidCredentialsError,
27
+ DimplexAuthInvalidGrantError,
28
+ DimplexAuthParseError,
29
+ DimplexAuthTransientError,
30
+ classify_oauth_token_error,
31
+ oauth_error_summary,
32
+ )
33
+
34
+ _LOGGER = logging.getLogger(__name__)
35
+
36
+ TokenListener = Callable[["TokenBundle"], Awaitable[None] | None]
37
+
38
+ # Max redirect hops while capturing the MSAL auth-code redirect.
39
+ _MAX_REDIRECT_HOPS = 20
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class TokenBundle:
44
+ """Public, serialisable snapshot of auth tokens."""
45
+
46
+ access_token: str | None = None
47
+ refresh_token: str | None = None
48
+ expires_at: float = 0.0
49
+
50
+ def as_dict(self) -> dict[str, Any]:
51
+ """Return a plain dict suitable for JSON / config-entry storage."""
52
+ return {
53
+ "access_token": self.access_token,
54
+ "refresh_token": self.refresh_token,
55
+ "expires_at": self.expires_at,
56
+ }
57
+
58
+ @classmethod
59
+ def from_mapping(cls, data: dict[str, Any] | None) -> TokenBundle:
60
+ """Build a bundle from a dict-like token store."""
61
+ if not data:
62
+ return cls()
63
+ return cls(
64
+ access_token=data.get("access_token"),
65
+ refresh_token=data.get("refresh_token"),
66
+ expires_at=float(data.get("expires_at") or 0),
67
+ )
68
+
69
+
70
+ class AuthManager:
71
+ """Manages authentication for Dimplex Control."""
72
+
73
+ def __init__(
74
+ self,
75
+ session: aiohttp.ClientSession,
76
+ token_data: dict[str, Any] | TokenBundle | None = None,
77
+ *,
78
+ on_token_update: TokenListener | None = None,
79
+ ):
80
+ """Initialize the auth manager."""
81
+ self._session = session
82
+ self._on_token_update = on_token_update
83
+ bundle = token_data if isinstance(token_data, TokenBundle) else TokenBundle.from_mapping(token_data)
84
+ self._access_token: str | None = bundle.access_token
85
+ self._refresh_token: str | None = bundle.refresh_token
86
+ self._expires_at: float = bundle.expires_at
87
+
88
+ @property
89
+ def is_authenticated(self) -> bool:
90
+ """Check if we have a valid access token."""
91
+ return self._access_token is not None and time.time() < self._expires_at
92
+
93
+ def export_tokens(self) -> TokenBundle:
94
+ """Return the current token snapshot (safe for persistence)."""
95
+ return TokenBundle(
96
+ access_token=self._access_token,
97
+ refresh_token=self._refresh_token,
98
+ expires_at=self._expires_at,
99
+ )
100
+
101
+ def apply_tokens(self, bundle: TokenBundle | dict[str, Any]) -> None:
102
+ """Replace in-memory tokens from a :class:`TokenBundle` or dict."""
103
+ if not isinstance(bundle, TokenBundle):
104
+ bundle = TokenBundle.from_mapping(bundle)
105
+ self._access_token = bundle.access_token
106
+ self._refresh_token = bundle.refresh_token
107
+ self._expires_at = bundle.expires_at
108
+
109
+ async def get_access_token(self) -> str:
110
+ """Get a valid access token, refreshing if necessary."""
111
+ if not self._refresh_token:
112
+ raise DimplexAuthInvalidGrantError(
113
+ "No refresh token available. User must authenticate first.",
114
+ code="missing_refresh_token",
115
+ )
116
+
117
+ if self.is_authenticated:
118
+ assert self._access_token is not None
119
+ return self._access_token
120
+
121
+ # Token expired or missing, try refresh
122
+ await self.refresh_tokens()
123
+ if not self._access_token:
124
+ raise DimplexAuthInvalidGrantError(
125
+ "Token refresh succeeded but no access token was returned.",
126
+ code="missing_access_token",
127
+ )
128
+ return self._access_token
129
+
130
+ async def refresh_tokens(self) -> None:
131
+ """Refresh the access token using the refresh token."""
132
+ _LOGGER.debug("Refreshing access token")
133
+ payload = {
134
+ "client_id": CLIENT_ID,
135
+ "grant_type": "refresh_token",
136
+ "refresh_token": self._refresh_token,
137
+ "scope": SCOPE,
138
+ "client_info": "1",
139
+ }
140
+
141
+ try:
142
+ async with self._session.post(f"{AUTH_URL}/token", data=payload) as resp:
143
+ if resp.status != HTTP_OK:
144
+ text = await resp.text()
145
+ _LOGGER.error(
146
+ "Failed to refresh token: HTTP %s (%s)",
147
+ resp.status,
148
+ _safe_error_summary(text),
149
+ )
150
+ raise classify_oauth_token_error(resp.status, text)
151
+
152
+ data = await resp.json()
153
+ await self._update_tokens(data)
154
+ except DimplexAuthError:
155
+ raise
156
+ except aiohttp.ClientError as exc:
157
+ raise DimplexAuthTransientError(
158
+ f"Network error while refreshing token: {type(exc).__name__}",
159
+ details=str(exc)[:200],
160
+ ) from exc
161
+
162
+ async def _update_tokens(self, data: dict[str, Any]) -> None:
163
+ """Update internal token state from API response."""
164
+ self._access_token = data.get("access_token")
165
+ # Some refresh responses omit a new refresh token — keep the old one.
166
+ if data.get("refresh_token"):
167
+ self._refresh_token = data.get("refresh_token")
168
+ expires_in = data.get("expires_in", 3600)
169
+ self._expires_at = time.time() + float(expires_in) - 60 # Buffer 60s
170
+ if self._on_token_update is not None:
171
+ result = self._on_token_update(self.export_tokens())
172
+ if inspect.isawaitable(result):
173
+ await result
174
+
175
+ def save_tokens(self, file_path: str) -> None:
176
+ """Save current tokens to a JSON file."""
177
+ data = self.export_tokens().as_dict()
178
+ with open(file_path, "w", encoding="utf-8") as f:
179
+ json.dump(data, f, indent=2)
180
+ _LOGGER.info("Tokens saved to %s", file_path)
181
+
182
+ @classmethod
183
+ def load_tokens(cls, file_path: str) -> dict[str, Any] | None:
184
+ """Load tokens from a JSON file."""
185
+ if not os.path.exists(file_path):
186
+ return None
187
+ try:
188
+ with open(file_path, encoding="utf-8") as f:
189
+ data = json.load(f)
190
+ if isinstance(data, dict):
191
+ return data
192
+ return None
193
+ except Exception as e:
194
+ _LOGGER.error("Failed to load tokens from %s: %s", file_path, type(e).__name__)
195
+ return None
196
+
197
+ def get_login_url(self) -> str:
198
+ """Generate the login URL for the user to visit."""
199
+ params = {
200
+ "client_id": CLIENT_ID,
201
+ "response_type": "code",
202
+ "redirect_uri": REDIRECT_URI,
203
+ "scope": SCOPE,
204
+ "response_mode": "query",
205
+ }
206
+ return f"{AUTH_URL}/authorize?{urlencode(params)}"
207
+
208
+ async def exchange_code(self, code: str) -> None:
209
+ """Exchange authorization code for tokens."""
210
+ payload = {
211
+ "client_id": CLIENT_ID,
212
+ "grant_type": "authorization_code",
213
+ "code": code,
214
+ "redirect_uri": REDIRECT_URI,
215
+ "scope": SCOPE,
216
+ }
217
+
218
+ _LOGGER.debug("Exchanging authorization code for tokens")
219
+ try:
220
+ async with self._session.post(f"{AUTH_URL}/token", data=payload) as resp:
221
+ if resp.status != HTTP_OK:
222
+ text = await resp.text()
223
+ _LOGGER.error(
224
+ "Token exchange failed: HTTP %s (%s)",
225
+ resp.status,
226
+ _safe_error_summary(text),
227
+ )
228
+ raise classify_oauth_token_error(resp.status, text)
229
+
230
+ data = await resp.json()
231
+ await self._update_tokens(data)
232
+ except DimplexAuthError:
233
+ raise
234
+ except aiohttp.ClientError as exc:
235
+ raise DimplexAuthTransientError(
236
+ f"Network error while exchanging code: {type(exc).__name__}",
237
+ details=str(exc)[:200],
238
+ ) from exc
239
+
240
+ @staticmethod
241
+ def _build_cookie_header(cookie_jar: Any, url: str) -> str:
242
+ """Build an unquoted Cookie header from an aiohttp cookie jar.
243
+
244
+ Python's http.cookies wraps values containing +, /, or = in
245
+ double-quotes, but Azure AD B2C expects raw unquoted values.
246
+ """
247
+ filtered = cookie_jar.filter_cookies(url)
248
+ return "; ".join(f"{m.key}={m.value}" for m in filtered.values())
249
+
250
+ @staticmethod
251
+ def _parse_b2c_login_page(html: str, page_url: str) -> dict[str, str]:
252
+ """Extract B2C form fields from the login page HTML.
253
+
254
+ Returns a dict with csrf, tx, p, post_url, confirmed_url.
255
+ Raises DimplexAuthParseError if required fields cannot be found.
256
+ """
257
+ csrf_match = re.search(r'"csrf"\s*:\s*"([^"]+)"', html)
258
+ if not csrf_match:
259
+ raise DimplexAuthParseError("Could not find CSRF token in B2C login page")
260
+ csrf = csrf_match.group(1)
261
+
262
+ tx_match = re.search(r'"transId"\s*:\s*"([^"]+)"', html)
263
+ if not tx_match:
264
+ raise DimplexAuthParseError("Could not find transId in B2C login page")
265
+ tx = tx_match.group(1)
266
+
267
+ # Build base URL by stripping the authorize endpoint.
268
+ # The B2C login page URL contains /tfp/{tenant}/{policy}/oauth2/v2.0/authorize
269
+ # and may redirect to /{tenant}/{policy}/oauth2/v2.0/authorize
270
+ if "/oauth2/v2.0/authorize" in page_url:
271
+ base_url = page_url.split("/oauth2/v2.0/authorize")[0]
272
+ else:
273
+ parsed = urlparse(page_url)
274
+ base_url = f"{parsed.scheme}://{parsed.netloc}/gdhvb2c.onmicrosoft.com/{B2C_POLICY}"
275
+
276
+ return {
277
+ "csrf": csrf,
278
+ "tx": tx,
279
+ "p": B2C_POLICY,
280
+ "post_url": f"{base_url}/SelfAsserted?tx={tx}&p={B2C_POLICY}",
281
+ "confirmed_url": f"{base_url}/api/CombinedSigninAndSignup/confirmed",
282
+ }
283
+
284
+ async def headless_login(self, email: str, password: str) -> None:
285
+ """Perform a headless login via Azure AD B2C to obtain tokens.
286
+
287
+ Uses direct HTTP credential submission so users don't need to
288
+ manually extract auth codes from browser network traffic.
289
+ """
290
+ jar = aiohttp.CookieJar(unsafe=True)
291
+ start_url = self.get_login_url()
292
+
293
+ try:
294
+ async with aiohttp.ClientSession(cookie_jar=jar) as session:
295
+ # Step 1: GET the auth URI, follow redirects to B2C login page
296
+ _LOGGER.debug("Fetching B2C login page")
297
+ try:
298
+ async with session.get(start_url, allow_redirects=True) as resp:
299
+ login_html = await resp.text()
300
+ page_url = str(resp.url)
301
+ if resp.status != HTTP_OK:
302
+ if resp.status >= 500:
303
+ raise DimplexAuthTransientError(
304
+ f"B2C login page returned HTTP {resp.status}",
305
+ status=resp.status,
306
+ )
307
+ raise DimplexAuthError(
308
+ f"B2C login page returned HTTP {resp.status}",
309
+ status=resp.status,
310
+ reauth_required=False,
311
+ )
312
+ except aiohttp.ClientError as exc:
313
+ raise DimplexAuthTransientError(
314
+ f"Network error fetching B2C login page: {type(exc).__name__}",
315
+ details=str(exc)[:200],
316
+ ) from exc
317
+
318
+ # Step 2: Parse the login page for CSRF, transaction ID, policy
319
+ fields = self._parse_b2c_login_page(login_html, page_url)
320
+ _LOGGER.debug(
321
+ "Parsed B2C login page (csrf_len=%s tx_len=%s p=%s)",
322
+ len(fields["csrf"]),
323
+ len(fields["tx"]),
324
+ fields["p"],
325
+ )
326
+
327
+ # Step 3: POST credentials to SelfAsserted endpoint
328
+ post_data = {
329
+ "request_type": "RESPONSE",
330
+ "email": email,
331
+ "password": password,
332
+ }
333
+ parsed_page = urlparse(page_url)
334
+ origin = f"{parsed_page.scheme}://{parsed_page.netloc}"
335
+ post_headers = {
336
+ "X-CSRF-TOKEN": fields["csrf"],
337
+ "X-Requested-With": "XMLHttpRequest",
338
+ "Referer": page_url,
339
+ "Origin": origin,
340
+ "Accept": "application/json, text/javascript, */*; q=0.01",
341
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
342
+ }
343
+
344
+ # Build an unquoted Cookie header — aiohttp wraps values
345
+ # containing +/= in double-quotes, but B2C requires raw values.
346
+ cookie_header = self._build_cookie_header(jar, fields["post_url"])
347
+ post_headers["Cookie"] = cookie_header
348
+ _LOGGER.debug("Submitting credentials to SelfAsserted endpoint")
349
+
350
+ # Use DummyCookieJar so POST response cookies aren't
351
+ # re-injected with quoted values on the next request.
352
+ async with aiohttp.ClientSession(
353
+ cookie_jar=aiohttp.DummyCookieJar(),
354
+ ) as raw_session:
355
+ try:
356
+ async with raw_session.post(
357
+ fields["post_url"],
358
+ data=post_data,
359
+ headers=post_headers,
360
+ allow_redirects=False,
361
+ ) as resp:
362
+ body = await resp.text()
363
+ if resp.status != HTTP_OK:
364
+ if resp.status >= 500:
365
+ raise DimplexAuthTransientError(
366
+ f"Credential submission returned HTTP {resp.status}",
367
+ status=resp.status,
368
+ )
369
+ raise DimplexAuthError(
370
+ f"Credential submission returned HTTP {resp.status}",
371
+ status=resp.status,
372
+ )
373
+ try:
374
+ resp_data = json.loads(body)
375
+ if str(resp_data.get("status")) == "400":
376
+ raise DimplexAuthInvalidCredentialsError("Invalid email or password")
377
+ except json.JSONDecodeError:
378
+ pass
379
+
380
+ # Merge POST response cookies into the cookie header
381
+ cookies: dict[str, str] = {}
382
+ for part in cookie_header.split("; "):
383
+ if "=" in part:
384
+ n, v = part.split("=", 1)
385
+ cookies[n] = v
386
+ for raw_sc in resp.headers.getall("Set-Cookie", []):
387
+ sc_pair = raw_sc.split(";", 1)[0]
388
+ if "=" in sc_pair:
389
+ n, v = sc_pair.split("=", 1)
390
+ cookies[n] = v
391
+ cookie_header = "; ".join(f"{n}={v}" for n, v in cookies.items())
392
+ except DimplexAuthError:
393
+ raise
394
+ except aiohttp.ClientError as exc:
395
+ raise DimplexAuthTransientError(
396
+ f"Network error submitting credentials: {type(exc).__name__}",
397
+ details=str(exc)[:200],
398
+ ) from exc
399
+
400
+ # Step 4: GET the confirmed endpoint and follow redirects
401
+ confirmed_qs = f"rememberMe=false&csrf_token={fields['csrf']}&tx={fields['tx']}&p={fields['p']}"
402
+ next_url: str = fields["confirmed_url"] + "?" + confirmed_qs
403
+ confirmed_headers = {"Cookie": cookie_header}
404
+
405
+ for _ in range(_MAX_REDIRECT_HOPS):
406
+ _LOGGER.debug("Following B2C redirect hop")
407
+ try:
408
+ async with raw_session.get(
409
+ next_url,
410
+ headers=confirmed_headers,
411
+ allow_redirects=False,
412
+ ) as resp:
413
+ resp_body = await resp.text()
414
+ if resp.status in (301, 302, 303, 307, 308):
415
+ location = resp.headers.get("Location", "")
416
+ if not location:
417
+ raise DimplexAuthParseError("Redirect without Location header")
418
+ if location.startswith(REDIRECT_URI) and (
419
+ len(location) == len(REDIRECT_URI) or location[len(REDIRECT_URI)] in ("?", "/")
420
+ ):
421
+ parsed = urlparse(location)
422
+ query = parse_qs(parsed.query)
423
+ code = query.get("code", [""])[0]
424
+ if not code:
425
+ raise DimplexAuthParseError("Redirect URL missing auth code")
426
+ await self.exchange_code(code)
427
+ return
428
+ if not location.startswith("http"):
429
+ location = (
430
+ f"{parsed_page.scheme}://{parsed_page.netloc}" + location
431
+ if location.startswith("/")
432
+ else location
433
+ )
434
+ next_url = location
435
+ continue
436
+ if resp.status == HTTP_OK:
437
+ redirect_match = re.search(
438
+ rf"({re.escape(REDIRECT_URI)}\?[^\s\"'<]+)",
439
+ resp_body,
440
+ )
441
+ if redirect_match:
442
+ parsed = urlparse(redirect_match.group(1))
443
+ query = parse_qs(parsed.query)
444
+ code = query.get("code", [""])[0]
445
+ if code:
446
+ await self.exchange_code(code)
447
+ return
448
+ raise DimplexAuthParseError("Reached 200 response without finding redirect URL")
449
+ if resp.status >= 500:
450
+ raise DimplexAuthTransientError(
451
+ f"Unexpected HTTP {resp.status} during redirect chain",
452
+ status=resp.status,
453
+ )
454
+ raise DimplexAuthError(
455
+ f"Unexpected HTTP {resp.status} during redirect chain",
456
+ status=resp.status,
457
+ )
458
+ except DimplexAuthError:
459
+ raise
460
+ except aiohttp.ClientError as exc:
461
+ raise DimplexAuthTransientError(
462
+ f"Network error during redirect chain: {type(exc).__name__}",
463
+ details=str(exc)[:200],
464
+ ) from exc
465
+
466
+ raise DimplexAuthTransientError(
467
+ "Exceeded maximum redirect hops without capturing auth code",
468
+ code="redirect_exhausted",
469
+ )
470
+ except DimplexAuthError:
471
+ raise
472
+
473
+
474
+ def _safe_error_summary(body: str) -> str:
475
+ """Summarise an OAuth error body without echoing secrets."""
476
+ return oauth_error_summary(body)
@@ -0,0 +1,183 @@
1
+ """Exceptions for Dimplex Controller."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+
9
+ class DimplexError(Exception):
10
+ """Base exception for Dimplex Controller."""
11
+
12
+
13
+ class DimplexAuthError(DimplexError):
14
+ """Authentication or token lifecycle failure.
15
+
16
+ Attributes:
17
+ code: Stable machine-readable identifier (e.g. ``invalid_grant``).
18
+ reauth_required: Caller should prompt for credentials / full login again.
19
+ transient: Failure may succeed on retry (network, 5xx, rate limit).
20
+ status: Optional HTTP status from the auth endpoint.
21
+ details: Optional short server message (never a full token).
22
+ """
23
+
24
+ code: str = "auth_error"
25
+ reauth_required: bool = False
26
+ transient: bool = False
27
+
28
+ def __init__(
29
+ self,
30
+ message: str,
31
+ *,
32
+ code: str | None = None,
33
+ reauth_required: bool | None = None,
34
+ transient: bool | None = None,
35
+ status: int | None = None,
36
+ details: str | None = None,
37
+ ) -> None:
38
+ super().__init__(message)
39
+ if code is not None:
40
+ self.code = code
41
+ if reauth_required is not None:
42
+ self.reauth_required = reauth_required
43
+ if transient is not None:
44
+ self.transient = transient
45
+ self.status = status
46
+ self.details = details
47
+
48
+
49
+ class DimplexAuthInvalidGrantError(DimplexAuthError):
50
+ """Refresh token or authorization code rejected; full re-login required."""
51
+
52
+ code = "invalid_grant"
53
+ reauth_required = True
54
+ transient = False
55
+
56
+
57
+ class DimplexAuthInvalidCredentialsError(DimplexAuthError):
58
+ """Email/password rejected by B2C."""
59
+
60
+ code = "invalid_credentials"
61
+ reauth_required = True
62
+ transient = False
63
+
64
+
65
+ class DimplexAuthParseError(DimplexAuthError):
66
+ """Could not parse B2C HTML or OAuth redirect payloads."""
67
+
68
+ code = "parse_error"
69
+ reauth_required = False
70
+ transient = False
71
+
72
+
73
+ class DimplexAuthTransientError(DimplexAuthError):
74
+ """Temporary auth infrastructure failure; retry may help."""
75
+
76
+ code = "transient"
77
+ reauth_required = False
78
+ transient = True
79
+
80
+
81
+ class DimplexApiError(DimplexError):
82
+ """Exception for API errors."""
83
+
84
+ def __init__(self, status: int, message: str):
85
+ self.status = status
86
+ self.message = message
87
+ super().__init__(f"API Error {status}: {message}")
88
+
89
+
90
+ class DimplexConnectionError(DimplexError):
91
+ """Exception for connection errors."""
92
+
93
+
94
+ def classify_oauth_token_error(status: int, body: str) -> DimplexAuthError:
95
+ """Map an OAuth token-endpoint failure to a structured :class:`DimplexAuthError`.
96
+
97
+ Callers can use ``error.reauth_required`` vs ``error.transient`` to decide
98
+ whether to open a reauth flow or retry later.
99
+ """
100
+ error_code, description = _parse_oauth_error_body(body)
101
+ detail = description or _truncate(body, 200)
102
+
103
+ if status >= 500 or status == 429:
104
+ return DimplexAuthTransientError(
105
+ f"Auth endpoint temporarily unavailable (HTTP {status})",
106
+ status=status,
107
+ details=detail,
108
+ )
109
+
110
+ if error_code in {"invalid_grant", "invalid_token", "expired_token"}:
111
+ return DimplexAuthInvalidGrantError(
112
+ f"Refresh/authorization grant rejected: {error_code}",
113
+ status=status,
114
+ details=detail,
115
+ )
116
+
117
+ if error_code in {"invalid_client", "unauthorized_client"}:
118
+ return DimplexAuthInvalidGrantError(
119
+ f"OAuth client rejected: {error_code}",
120
+ status=status,
121
+ details=detail,
122
+ )
123
+
124
+ if status in {400, 401, 403}:
125
+ # Treat remaining client errors as needing a fresh login rather than a silent retry.
126
+ return DimplexAuthInvalidGrantError(
127
+ f"Token request failed (HTTP {status})",
128
+ status=status,
129
+ details=detail,
130
+ code=error_code or "invalid_grant",
131
+ )
132
+
133
+ return DimplexAuthError(
134
+ f"Token request failed (HTTP {status})",
135
+ code=error_code or "auth_error",
136
+ status=status,
137
+ details=detail,
138
+ reauth_required=False,
139
+ transient=False,
140
+ )
141
+
142
+
143
+ def _parse_oauth_error_body(body: str) -> tuple[str | None, str | None]:
144
+ """Extract ``error`` / ``error_description`` from an OAuth error JSON body."""
145
+ text = (body or "").strip()
146
+ if not text:
147
+ return None, None
148
+ try:
149
+ data: Any = json.loads(text)
150
+ except json.JSONDecodeError:
151
+ return None, _truncate(text, 200)
152
+ if not isinstance(data, dict):
153
+ return None, _truncate(text, 200)
154
+ error = data.get("error")
155
+ description = data.get("error_description") or data.get("error_codes")
156
+ error_s = str(error) if error is not None else None
157
+ if isinstance(description, list):
158
+ description_s = ", ".join(str(x) for x in description)
159
+ elif description is not None:
160
+ description_s = str(description)
161
+ else:
162
+ description_s = None
163
+ return error_s, _truncate(description_s, 200) if description_s else None
164
+
165
+
166
+ def oauth_error_summary(body: str) -> str:
167
+ """Human-readable OAuth error summary safe for logs (no raw tokens)."""
168
+ error_code, description = _parse_oauth_error_body(body)
169
+ parts = [p for p in (error_code, description) if p]
170
+ if parts:
171
+ return " — ".join(parts)
172
+ if not body:
173
+ return "empty body"
174
+ return f"non-json body ({len(body)} bytes)"
175
+
176
+
177
+ def _truncate(value: str | None, limit: int) -> str | None:
178
+ if value is None:
179
+ return None
180
+ value = value.replace("\n", " ").strip()
181
+ if len(value) <= limit:
182
+ return value
183
+ return value[: limit - 3] + "..."
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "dimplex-controller"
3
- version = "0.8.0"
3
+ version = "0.9.0"
4
4
  description = "Python client for Dimplex heating controllers (GDHV IoT)"
5
5
  authors = ["Kieran Roper"]
6
6
  license = "MIT"
@@ -1,398 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import logging
5
- import os
6
- import re
7
- import time
8
- from collections.abc import Awaitable, Callable
9
- from dataclasses import dataclass
10
- from typing import Any
11
- from urllib.parse import parse_qs, urlparse
12
-
13
- import aiohttp
14
-
15
- from .const import (
16
- AUTH_URL,
17
- B2C_POLICY,
18
- CLIENT_ID,
19
- HTTP_OK,
20
- REDIRECT_URI,
21
- SCOPE,
22
- )
23
- from .exceptions import DimplexAuthError
24
-
25
- _LOGGER = logging.getLogger(__name__)
26
-
27
- TokenListener = Callable[["TokenBundle"], Awaitable[None] | None]
28
-
29
-
30
- @dataclass(frozen=True, slots=True)
31
- class TokenBundle:
32
- """Public, serialisable snapshot of auth tokens."""
33
-
34
- access_token: str | None = None
35
- refresh_token: str | None = None
36
- expires_at: float = 0.0
37
-
38
- def as_dict(self) -> dict[str, Any]:
39
- """Return a plain dict suitable for JSON / config-entry storage."""
40
- return {
41
- "access_token": self.access_token,
42
- "refresh_token": self.refresh_token,
43
- "expires_at": self.expires_at,
44
- }
45
-
46
- @classmethod
47
- def from_mapping(cls, data: dict[str, Any] | None) -> TokenBundle:
48
- """Build a bundle from a dict-like token store."""
49
- if not data:
50
- return cls()
51
- return cls(
52
- access_token=data.get("access_token"),
53
- refresh_token=data.get("refresh_token"),
54
- expires_at=float(data.get("expires_at") or 0),
55
- )
56
-
57
-
58
- class AuthManager:
59
- """Manages authentication for Dimplex Control."""
60
-
61
- def __init__(
62
- self,
63
- session: aiohttp.ClientSession,
64
- token_data: dict[str, Any] | TokenBundle | None = None,
65
- *,
66
- on_token_update: TokenListener | None = None,
67
- ):
68
- """Initialize the auth manager."""
69
- self._session = session
70
- self._on_token_update = on_token_update
71
- bundle = token_data if isinstance(token_data, TokenBundle) else TokenBundle.from_mapping(token_data)
72
- self._access_token: str | None = bundle.access_token
73
- self._refresh_token: str | None = bundle.refresh_token
74
- self._expires_at: float = bundle.expires_at
75
-
76
- @property
77
- def is_authenticated(self) -> bool:
78
- """Check if we have a valid access token."""
79
- return self._access_token is not None and time.time() < self._expires_at
80
-
81
- def export_tokens(self) -> TokenBundle:
82
- """Return the current token snapshot (safe for persistence)."""
83
- return TokenBundle(
84
- access_token=self._access_token,
85
- refresh_token=self._refresh_token,
86
- expires_at=self._expires_at,
87
- )
88
-
89
- def apply_tokens(self, bundle: TokenBundle | dict[str, Any]) -> None:
90
- """Replace in-memory tokens from a :class:`TokenBundle` or dict."""
91
- if not isinstance(bundle, TokenBundle):
92
- bundle = TokenBundle.from_mapping(bundle)
93
- self._access_token = bundle.access_token
94
- self._refresh_token = bundle.refresh_token
95
- self._expires_at = bundle.expires_at
96
-
97
- async def get_access_token(self) -> str:
98
- """Get a valid access token, refreshing if necessary."""
99
- if not self._refresh_token:
100
- raise DimplexAuthError("No refresh token available. User must authenticate first.")
101
-
102
- if self.is_authenticated:
103
- assert self._access_token is not None
104
- return self._access_token
105
-
106
- # Token expired or missing, try refresh
107
- await self.refresh_tokens()
108
- if not self._access_token:
109
- raise DimplexAuthError("Token refresh succeeded but no access token was returned.")
110
- return self._access_token
111
-
112
- async def refresh_tokens(self) -> None:
113
- """Refresh the access token using the refresh token."""
114
- _LOGGER.debug("Refreshing access token")
115
- payload = {
116
- "client_id": CLIENT_ID,
117
- "grant_type": "refresh_token",
118
- "refresh_token": self._refresh_token,
119
- "scope": SCOPE,
120
- "client_info": "1",
121
- }
122
-
123
- async with self._session.post(f"{AUTH_URL}/token", data=payload) as resp:
124
- if resp.status != HTTP_OK:
125
- text = await resp.text()
126
- _LOGGER.error("Failed to refresh token: %s", text)
127
- raise DimplexAuthError(f"Failed to refresh token: {resp.status} - {text}")
128
-
129
- data = await resp.json()
130
- self._update_tokens(data)
131
-
132
- def _update_tokens(self, data: dict[str, Any]) -> None:
133
- """Update internal token state from API response."""
134
- self._access_token = data.get("access_token")
135
- # Some refresh responses omit a new refresh token — keep the old one.
136
- if data.get("refresh_token"):
137
- self._refresh_token = data.get("refresh_token")
138
- expires_in = data.get("expires_in", 3600)
139
- self._expires_at = time.time() + float(expires_in) - 60 # Buffer 60s
140
- if self._on_token_update is not None:
141
- result = self._on_token_update(self.export_tokens())
142
- if hasattr(result, "__await__"):
143
- # Listener may be async; fire-and-forget is unsafe here — callers
144
- # should prefer sync listeners or schedule from the callback.
145
- pass
146
-
147
- def save_tokens(self, file_path: str) -> None:
148
- """Save current tokens to a JSON file."""
149
- data = self.export_tokens().as_dict()
150
- with open(file_path, "w", encoding="utf-8") as f:
151
- json.dump(data, f, indent=2)
152
- _LOGGER.info("Tokens saved to %s", file_path)
153
-
154
- @classmethod
155
- def load_tokens(cls, file_path: str) -> dict[str, Any] | None:
156
- """Load tokens from a JSON file."""
157
- if not os.path.exists(file_path):
158
- return None
159
- try:
160
- with open(file_path, encoding="utf-8") as f:
161
- data = json.load(f)
162
- if isinstance(data, dict):
163
- return data
164
- return None
165
- except Exception as e:
166
- _LOGGER.error("Failed to load tokens from %s: %s", file_path, e)
167
- return None
168
-
169
- def get_login_url(self) -> str:
170
- """Generate the login URL for the user to visit."""
171
- # Note: This is a simplified URL generation.
172
- # In a real app, we might need state, nonce, code_challenge (PKCE).
173
- # Based on logs, iOS app uses standard OAuth2.
174
- # Constructing a URL for manual copy-paste might be tricky if it strictly requires a custom scheme redirect.
175
- # But we can try the standard authorize endpoint.
176
-
177
- params = {
178
- "client_id": CLIENT_ID,
179
- "response_type": "code",
180
- "redirect_uri": REDIRECT_URI,
181
- "scope": SCOPE,
182
- "response_mode": "query",
183
- }
184
- from urllib.parse import urlencode
185
-
186
- return f"{AUTH_URL}/authorize?{urlencode(params)}"
187
-
188
- async def exchange_code(self, code: str) -> None:
189
- """Exchange authorization code for tokens."""
190
- payload = {
191
- "client_id": CLIENT_ID,
192
- "grant_type": "authorization_code",
193
- "code": code,
194
- "redirect_uri": REDIRECT_URI,
195
- "scope": SCOPE,
196
- }
197
-
198
- _LOGGER.info(f"Exchanging code for tokens at {AUTH_URL}/token")
199
- client_id = payload["client_id"]
200
- redirect_uri = payload["redirect_uri"]
201
- code_preview = code[:10]
202
- _LOGGER.info(f"Payload: client_id={client_id}, redirect_uri={redirect_uri}, " f"code={code_preview}...")
203
-
204
- async with self._session.post(f"{AUTH_URL}/token", data=payload) as resp:
205
- _LOGGER.info(f"Token exchange response status: {resp.status}")
206
- if resp.status != HTTP_OK:
207
- text = await resp.text()
208
- _LOGGER.error(f"Token exchange failed: {text}")
209
- raise DimplexAuthError(f"Failed to exchange code: {text}")
210
-
211
- data = await resp.json()
212
- self._update_tokens(data)
213
-
214
- @staticmethod
215
- def _build_cookie_header(cookie_jar, url: str) -> str:
216
- """Build an unquoted Cookie header from an aiohttp cookie jar.
217
-
218
- Python's http.cookies wraps values containing +, /, or = in
219
- double-quotes, but Azure AD B2C expects raw unquoted values.
220
- """
221
- filtered = cookie_jar.filter_cookies(url)
222
- return "; ".join(f"{m.key}={m.value}" for m in filtered.values())
223
-
224
- @staticmethod
225
- def _parse_b2c_login_page(html: str, page_url: str) -> dict:
226
- """Extract B2C form fields from the login page HTML.
227
-
228
- Returns a dict with csrf, tx, p, post_url, confirmed_url.
229
- Raises DimplexAuthError if required fields cannot be found.
230
- """
231
- csrf_match = re.search(r'"csrf"\s*:\s*"([^"]+)"', html)
232
- if not csrf_match:
233
- raise DimplexAuthError("Could not find CSRF token in B2C login page")
234
- csrf = csrf_match.group(1)
235
-
236
- tx_match = re.search(r'"transId"\s*:\s*"([^"]+)"', html)
237
- if not tx_match:
238
- raise DimplexAuthError("Could not find transId in B2C login page")
239
- tx = tx_match.group(1)
240
-
241
- # Build base URL by stripping the authorize endpoint.
242
- # The B2C login page URL contains /tfp/{tenant}/{policy}/oauth2/v2.0/authorize
243
- # and may redirect to /{tenant}/{policy}/oauth2/v2.0/authorize
244
- if "/oauth2/v2.0/authorize" in page_url:
245
- base_url = page_url.split("/oauth2/v2.0/authorize")[0]
246
- else:
247
- parsed = urlparse(page_url)
248
- base_url = f"{parsed.scheme}://{parsed.netloc}" f"/gdhvb2c.onmicrosoft.com/{B2C_POLICY}"
249
-
250
- return {
251
- "csrf": csrf,
252
- "tx": tx,
253
- "p": B2C_POLICY,
254
- "post_url": f"{base_url}/SelfAsserted?tx={tx}&p={B2C_POLICY}",
255
- "confirmed_url": (f"{base_url}/api/CombinedSigninAndSignup/confirmed"),
256
- }
257
-
258
- async def headless_login(self, email: str, password: str) -> None:
259
- """Perform a headless login via Azure AD B2C to obtain tokens.
260
-
261
- Uses direct HTTP credential submission so users don't need to
262
- manually extract auth codes from browser network traffic.
263
- """
264
- jar = aiohttp.CookieJar(unsafe=True)
265
- start_url = self.get_login_url()
266
-
267
- async with aiohttp.ClientSession(cookie_jar=jar) as session:
268
- # Step 1: GET the auth URI, follow redirects to B2C login page
269
- _LOGGER.debug("Fetching B2C login page: %s", start_url)
270
- async with session.get(start_url, allow_redirects=True) as resp:
271
- login_html = await resp.text()
272
- page_url = str(resp.url)
273
- if resp.status != HTTP_OK:
274
- raise DimplexAuthError(f"B2C login page returned HTTP {resp.status}")
275
-
276
- # Step 2: Parse the login page for CSRF, transaction ID, policy
277
- fields = self._parse_b2c_login_page(login_html, page_url)
278
- _LOGGER.debug(
279
- "Parsed B2C login page: csrf=%s... tx=%s... p=%s",
280
- fields["csrf"][:16],
281
- fields["tx"][:40],
282
- fields["p"],
283
- )
284
-
285
- # Step 3: POST credentials to SelfAsserted endpoint
286
- post_data = {
287
- "request_type": "RESPONSE",
288
- "email": email,
289
- "password": password,
290
- }
291
- parsed_page = urlparse(page_url)
292
- origin = f"{parsed_page.scheme}://{parsed_page.netloc}"
293
- post_headers = {
294
- "X-CSRF-TOKEN": fields["csrf"],
295
- "X-Requested-With": "XMLHttpRequest",
296
- "Referer": page_url,
297
- "Origin": origin,
298
- "Accept": "application/json, text/javascript, */*; q=0.01",
299
- "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
300
- }
301
-
302
- # Build an unquoted Cookie header — aiohttp wraps values
303
- # containing +/= in double-quotes, but B2C requires raw values.
304
- cookie_header = self._build_cookie_header(jar, fields["post_url"])
305
- post_headers["Cookie"] = cookie_header
306
- _LOGGER.debug("Submitting credentials to %s", fields["post_url"])
307
-
308
- # Use DummyCookieJar so POST response cookies aren't
309
- # re-injected with quoted values on the next request.
310
- async with aiohttp.ClientSession(
311
- cookie_jar=aiohttp.DummyCookieJar(),
312
- ) as raw_session:
313
- async with raw_session.post(
314
- fields["post_url"],
315
- data=post_data,
316
- headers=post_headers,
317
- allow_redirects=False,
318
- ) as resp:
319
- body = await resp.text()
320
- if resp.status != HTTP_OK:
321
- raise DimplexAuthError(f"Credential submission returned HTTP {resp.status}")
322
- try:
323
- resp_data = json.loads(body)
324
- if str(resp_data.get("status")) == "400":
325
- raise DimplexAuthError("Invalid email or password")
326
- except json.JSONDecodeError:
327
- pass
328
-
329
- # Merge POST response cookies into the cookie header
330
- cookies: dict[str, str] = {}
331
- for part in cookie_header.split("; "):
332
- if "=" in part:
333
- n, v = part.split("=", 1)
334
- cookies[n] = v
335
- for raw_sc in resp.headers.getall("Set-Cookie", []):
336
- sc_pair = raw_sc.split(";", 1)[0]
337
- if "=" in sc_pair:
338
- n, v = sc_pair.split("=", 1)
339
- cookies[n] = v
340
- cookie_header = "; ".join(f"{n}={v}" for n, v in cookies.items())
341
-
342
- # Step 4: GET the confirmed endpoint and follow redirects
343
- confirmed_qs = (
344
- f"rememberMe=false" f"&csrf_token={fields['csrf']}" f"&tx={fields['tx']}" f"&p={fields['p']}"
345
- )
346
- next_url: str = fields["confirmed_url"] + "?" + confirmed_qs
347
- confirmed_headers = {"Cookie": cookie_header}
348
-
349
- for _ in range(20): # max redirect hops
350
- _LOGGER.debug("Following redirect: %s", next_url[:120])
351
- async with raw_session.get(
352
- next_url,
353
- headers=confirmed_headers,
354
- allow_redirects=False,
355
- ) as resp:
356
- resp_body = await resp.text()
357
- if resp.status in (301, 302, 303, 307, 308):
358
- location = resp.headers.get("Location", "")
359
- if not location:
360
- raise DimplexAuthError("Redirect without Location header")
361
- if location.startswith(REDIRECT_URI) and (
362
- len(location) == len(REDIRECT_URI) or location[len(REDIRECT_URI)] in ("?", "/")
363
- ):
364
- _LOGGER.debug(
365
- "Captured redirect with code: %s...",
366
- location[:120],
367
- )
368
- parsed = urlparse(location)
369
- query = parse_qs(parsed.query)
370
- code = query.get("code", [""])[0]
371
- if not code:
372
- raise DimplexAuthError("Redirect URL missing auth code")
373
- await self.exchange_code(code)
374
- return
375
- if not location.startswith("http"):
376
- location = (
377
- f"{parsed_page.scheme}://{parsed_page.netloc}" + location
378
- if location.startswith("/")
379
- else location
380
- )
381
- next_url = location
382
- continue
383
- if resp.status == HTTP_OK:
384
- redirect_match = re.search(
385
- rf"({re.escape(REDIRECT_URI)}\?[^\s\"'<]+)",
386
- resp_body,
387
- )
388
- if redirect_match:
389
- parsed = urlparse(redirect_match.group(1))
390
- query = parse_qs(parsed.query)
391
- code = query.get("code", [""])[0]
392
- if code:
393
- await self.exchange_code(code)
394
- return
395
- raise DimplexAuthError("Reached 200 response without finding redirect URL")
396
- raise DimplexAuthError(f"Unexpected HTTP {resp.status} during redirect chain")
397
-
398
- raise DimplexAuthError("Exceeded maximum redirect hops without capturing auth code")
@@ -1,22 +0,0 @@
1
- """Exceptions for Dimplex Controller."""
2
-
3
-
4
- class DimplexError(Exception):
5
- """Base exception for Dimplex Controller."""
6
-
7
-
8
- class DimplexAuthError(DimplexError):
9
- """Exception for authentication errors."""
10
-
11
-
12
- class DimplexApiError(DimplexError):
13
- """Exception for API errors."""
14
-
15
- def __init__(self, status: int, message: str):
16
- self.status = status
17
- self.message = message
18
- super().__init__(f"API Error {status}: {message}")
19
-
20
-
21
- class DimplexConnectionError(DimplexError):
22
- """Exception for connection errors."""