dimplex-controller 0.8.0__tar.gz → 0.10.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.10.0
4
4
  Summary: Python client for Dimplex heating controllers (GDHV IoT)
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -198,7 +198,11 @@ for appliance_id, telemetry in report.ApplianceTelemetryData.items():
198
198
  print(f"{appliance_id}: today={daily.total_kwh} kWh, lifetime={lifetime.total_kwh} kWh")
199
199
  ```
200
200
 
201
- `parse_telemetry_points` normalises firmware-varying point shapes. `summarise_energy` builds **daily** (local midnight) and **lifetime** totals. With `include_previous_period=True` the cloud often returns full history — filter client-side rather than trusting `days_back` alone.
201
+ `parse_telemetry_points` normalises firmware-varying point shapes. `summarise_energy` builds **daily** (local midnight) and **lifetime** totals **per register**. `T1` (off-peak / cheaper) and `T2` (peak / more expensive) must not be summed; parse with `VALUE_KEY_T1` / `VALUE_KEY_T2`. With `include_previous_period=True` the cloud often returns full history — filter client-side rather than trusting `days_back` alone.
202
+
203
+ ## Compatibility
204
+
205
+ See [docs/compatibility.md](docs/compatibility.md) for the library ↔ Home Assistant version matrix.
202
206
 
203
207
  ## Configuration
204
208
 
@@ -277,8 +281,37 @@ If `parse_telemetry_points` returns an empty list, the API likely returned an un
277
281
 
278
282
  The GDHV cloud API has rate limits. If you hit them, back off for a few minutes before retrying. The library does not currently implement automatic retries with back-off.
279
283
 
284
+
285
+ ## CLI
286
+
287
+ Install the package (or an editable install) to get the `dimplex` console script:
288
+
289
+ ```bash
290
+ pip install dimplex-controller
291
+ export DIMPLEX_REFRESH_TOKEN=... # never commit this
292
+ dimplex login
293
+ dimplex hubs
294
+ dimplex zones --hub <hub-id> -v
295
+ dimplex status <hub-id> <appliance-id>
296
+ dimplex energy <hub-id> --days 30
297
+ # control writes require --yes
298
+ dimplex boost <hub-id> <appliance-id> --minutes 60 --yes
299
+ ```
300
+
301
+ Tokens can also come from a JSON file (`--tokens-file` / `DIMPLEX_TOKENS_FILE`) with keys `refresh_token`, `access_token`, `expires_at`. Secrets are redacted in CLI output unless `--show-tokens` is passed.
302
+
280
303
  ## Contributing
281
304
 
305
+ ### Branch protection (`main`)
306
+
307
+ Pull requests into `main` must keep the **`ci`** GitHub Actions check green.
308
+
309
+ - Changes under `dimplex_controller/`, `tests/`, or CI config run **lint**, **pre-commit**, and the **pytest matrix** (Python 3.10–3.13). The `ci` job fails if any of those fail.
310
+ - Docs-only PRs still report a green `ci` without running the full matrix.
311
+
312
+ Direct pushes to `main` are blocked (PR + squash only; no force-push/delete). Commits must be signed (repo-wide rule).
313
+
314
+
282
315
  Contributions are welcome! Please read the [contributing guidelines](CONTRIBUTING.md) before opening a pull request.
283
316
 
284
317
  Key points:
@@ -170,7 +170,11 @@ for appliance_id, telemetry in report.ApplianceTelemetryData.items():
170
170
  print(f"{appliance_id}: today={daily.total_kwh} kWh, lifetime={lifetime.total_kwh} kWh")
171
171
  ```
172
172
 
173
- `parse_telemetry_points` normalises firmware-varying point shapes. `summarise_energy` builds **daily** (local midnight) and **lifetime** totals. With `include_previous_period=True` the cloud often returns full history — filter client-side rather than trusting `days_back` alone.
173
+ `parse_telemetry_points` normalises firmware-varying point shapes. `summarise_energy` builds **daily** (local midnight) and **lifetime** totals **per register**. `T1` (off-peak / cheaper) and `T2` (peak / more expensive) must not be summed; parse with `VALUE_KEY_T1` / `VALUE_KEY_T2`. With `include_previous_period=True` the cloud often returns full history — filter client-side rather than trusting `days_back` alone.
174
+
175
+ ## Compatibility
176
+
177
+ See [docs/compatibility.md](docs/compatibility.md) for the library ↔ Home Assistant version matrix.
174
178
 
175
179
  ## Configuration
176
180
 
@@ -249,8 +253,37 @@ If `parse_telemetry_points` returns an empty list, the API likely returned an un
249
253
 
250
254
  The GDHV cloud API has rate limits. If you hit them, back off for a few minutes before retrying. The library does not currently implement automatic retries with back-off.
251
255
 
256
+
257
+ ## CLI
258
+
259
+ Install the package (or an editable install) to get the `dimplex` console script:
260
+
261
+ ```bash
262
+ pip install dimplex-controller
263
+ export DIMPLEX_REFRESH_TOKEN=... # never commit this
264
+ dimplex login
265
+ dimplex hubs
266
+ dimplex zones --hub <hub-id> -v
267
+ dimplex status <hub-id> <appliance-id>
268
+ dimplex energy <hub-id> --days 30
269
+ # control writes require --yes
270
+ dimplex boost <hub-id> <appliance-id> --minutes 60 --yes
271
+ ```
272
+
273
+ Tokens can also come from a JSON file (`--tokens-file` / `DIMPLEX_TOKENS_FILE`) with keys `refresh_token`, `access_token`, `expires_at`. Secrets are redacted in CLI output unless `--show-tokens` is passed.
274
+
252
275
  ## Contributing
253
276
 
277
+ ### Branch protection (`main`)
278
+
279
+ Pull requests into `main` must keep the **`ci`** GitHub Actions check green.
280
+
281
+ - Changes under `dimplex_controller/`, `tests/`, or CI config run **lint**, **pre-commit**, and the **pytest matrix** (Python 3.10–3.13). The `ci` job fails if any of those fail.
282
+ - Docs-only PRs still report a green `ci` without running the full matrix.
283
+
284
+ Direct pushes to `main` are blocked (PR + squash only; no force-push/delete). Commits must be signed (repo-wide rule).
285
+
286
+
254
287
  Contributions are welcome! Please read the [contributing guidelines](CONTRIBUTING.md) before opening a pull request.
255
288
 
256
289
  Key points:
@@ -1,8 +1,18 @@
1
1
  """Dimplex Controller Client."""
2
2
 
3
3
  from .auth import TokenBundle
4
+ from .capabilities import ApplianceCapabilities, capabilities_for
4
5
  from .client import DimplexControl
5
- from .exceptions import DimplexApiError, DimplexAuthError, DimplexConnectionError, DimplexError
6
+ from .exceptions import (
7
+ DimplexApiError,
8
+ DimplexAuthError,
9
+ DimplexAuthInvalidCredentialsError,
10
+ DimplexAuthInvalidGrantError,
11
+ DimplexAuthParseError,
12
+ DimplexAuthTransientError,
13
+ DimplexConnectionError,
14
+ DimplexError,
15
+ )
6
16
  from .models import (
7
17
  Appliance,
8
18
  ApplianceModeFlag,
@@ -18,6 +28,7 @@ from .models import (
18
28
  Zone,
19
29
  )
20
30
  from .telemetry import (
31
+ VALUE_KEY_T1,
21
32
  VALUE_KEY_T2,
22
33
  EnergySummary,
23
34
  filter_telemetry_points,
@@ -28,6 +39,8 @@ from .telemetry import (
28
39
  __all__ = [
29
40
  "DimplexControl",
30
41
  "TokenBundle",
42
+ "ApplianceCapabilities",
43
+ "capabilities_for",
31
44
  "Hub",
32
45
  "Zone",
33
46
  "Appliance",
@@ -44,9 +57,14 @@ __all__ = [
44
57
  "filter_telemetry_points",
45
58
  "summarise_energy",
46
59
  "EnergySummary",
60
+ "VALUE_KEY_T1",
47
61
  "VALUE_KEY_T2",
48
62
  "DimplexError",
49
63
  "DimplexApiError",
50
64
  "DimplexAuthError",
65
+ "DimplexAuthInvalidGrantError",
66
+ "DimplexAuthInvalidCredentialsError",
67
+ "DimplexAuthParseError",
68
+ "DimplexAuthTransientError",
51
69
  "DimplexConnectionError",
52
70
  ]
@@ -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)