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.
- {dimplex_controller-0.8.0 → dimplex_controller-0.10.0}/PKG-INFO +35 -2
- {dimplex_controller-0.8.0 → dimplex_controller-0.10.0}/README.md +34 -1
- {dimplex_controller-0.8.0 → dimplex_controller-0.10.0}/dimplex_controller/__init__.py +19 -1
- dimplex_controller-0.10.0/dimplex_controller/auth.py +476 -0
- dimplex_controller-0.10.0/dimplex_controller/capabilities.py +178 -0
- dimplex_controller-0.10.0/dimplex_controller/cli.py +266 -0
- {dimplex_controller-0.8.0 → dimplex_controller-0.10.0}/dimplex_controller/client.py +185 -16
- dimplex_controller-0.10.0/dimplex_controller/exceptions.py +183 -0
- {dimplex_controller-0.8.0 → dimplex_controller-0.10.0}/dimplex_controller/telemetry.py +31 -15
- {dimplex_controller-0.8.0 → dimplex_controller-0.10.0}/pyproject.toml +4 -1
- dimplex_controller-0.8.0/dimplex_controller/auth.py +0 -398
- dimplex_controller-0.8.0/dimplex_controller/exceptions.py +0 -22
- {dimplex_controller-0.8.0 → dimplex_controller-0.10.0}/LICENSE +0 -0
- {dimplex_controller-0.8.0 → dimplex_controller-0.10.0}/dimplex_controller/const.py +0 -0
- {dimplex_controller-0.8.0 → dimplex_controller-0.10.0}/dimplex_controller/models.py +0 -0
- {dimplex_controller-0.8.0 → dimplex_controller-0.10.0}/dimplex_controller/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dimplex-controller
|
|
3
|
-
Version: 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
|
|
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
|
|
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
|
|
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)
|