dimplex-controller 0.2.1__tar.gz → 0.4.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.2.1 → dimplex_controller-0.4.0}/PKG-INFO +1 -1
- {dimplex_controller-0.2.1 → dimplex_controller-0.4.0}/dimplex_controller/__init__.py +4 -1
- dimplex_controller-0.4.0/dimplex_controller/auth.py +329 -0
- {dimplex_controller-0.2.1 → dimplex_controller-0.4.0}/dimplex_controller/client.py +61 -11
- {dimplex_controller-0.2.1 → dimplex_controller-0.4.0}/dimplex_controller/const.py +1 -0
- dimplex_controller-0.4.0/dimplex_controller/models.py +117 -0
- dimplex_controller-0.4.0/dimplex_controller/telemetry.py +133 -0
- {dimplex_controller-0.2.1 → dimplex_controller-0.4.0}/pyproject.toml +15 -2
- dimplex_controller-0.2.1/dimplex_controller/auth.py +0 -304
- dimplex_controller-0.2.1/dimplex_controller/models.py +0 -99
- {dimplex_controller-0.2.1 → dimplex_controller-0.4.0}/LICENSE +0 -0
- {dimplex_controller-0.2.1 → dimplex_controller-0.4.0}/README.md +0 -0
- {dimplex_controller-0.2.1 → dimplex_controller-0.4.0}/dimplex_controller/exceptions.py +0 -0
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from .client import DimplexControl
|
|
4
4
|
from .exceptions import DimplexApiError, DimplexAuthError, DimplexConnectionError, DimplexError
|
|
5
|
-
from .models import Appliance, ApplianceModeSettings, ApplianceStatus, Hub, Zone
|
|
5
|
+
from .models import Appliance, ApplianceModeSettings, ApplianceStatus, Hub, TsiEnergyReport, Zone
|
|
6
|
+
from .telemetry import parse_telemetry_points
|
|
6
7
|
|
|
7
8
|
__all__ = [
|
|
8
9
|
"DimplexControl",
|
|
@@ -11,6 +12,8 @@ __all__ = [
|
|
|
11
12
|
"Appliance",
|
|
12
13
|
"ApplianceStatus",
|
|
13
14
|
"ApplianceModeSettings",
|
|
15
|
+
"TsiEnergyReport",
|
|
16
|
+
"parse_telemetry_points",
|
|
14
17
|
"DimplexError",
|
|
15
18
|
"DimplexApiError",
|
|
16
19
|
"DimplexAuthError",
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from urllib.parse import parse_qs, urlparse
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
|
|
10
|
+
from .const import (
|
|
11
|
+
AUTH_URL,
|
|
12
|
+
B2C_POLICY,
|
|
13
|
+
CLIENT_ID,
|
|
14
|
+
HTTP_OK,
|
|
15
|
+
REDIRECT_URI,
|
|
16
|
+
SCOPE,
|
|
17
|
+
)
|
|
18
|
+
from .exceptions import DimplexAuthError
|
|
19
|
+
|
|
20
|
+
_LOGGER = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AuthManager:
|
|
24
|
+
"""Manages authentication for Dimplex Control."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, session: aiohttp.ClientSession, token_data: dict | None = None):
|
|
27
|
+
"""Initialize the auth manager."""
|
|
28
|
+
self._session = session
|
|
29
|
+
self._access_token: str | None = token_data.get("access_token") if token_data else None
|
|
30
|
+
self._refresh_token: str | None = token_data.get("refresh_token") if token_data else None
|
|
31
|
+
self._expires_at: float = token_data.get("expires_at", 0) if token_data else 0
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def is_authenticated(self) -> bool:
|
|
35
|
+
"""Check if we have a valid access token."""
|
|
36
|
+
return self._access_token is not None and time.time() < self._expires_at
|
|
37
|
+
|
|
38
|
+
async def get_access_token(self) -> str:
|
|
39
|
+
"""Get a valid access token, refreshing if necessary."""
|
|
40
|
+
if not self._refresh_token:
|
|
41
|
+
raise DimplexAuthError("No refresh token available. User must authenticate first.")
|
|
42
|
+
|
|
43
|
+
if self.is_authenticated:
|
|
44
|
+
return self._access_token # type: ignore[return-value]
|
|
45
|
+
|
|
46
|
+
# Token expired or missing, try refresh
|
|
47
|
+
await self.refresh_tokens()
|
|
48
|
+
return self._access_token # type: ignore[return-value]
|
|
49
|
+
|
|
50
|
+
async def refresh_tokens(self) -> None:
|
|
51
|
+
"""Refresh the access token using the refresh token."""
|
|
52
|
+
_LOGGER.debug("Refreshing access token")
|
|
53
|
+
payload = {
|
|
54
|
+
"client_id": CLIENT_ID,
|
|
55
|
+
"grant_type": "refresh_token",
|
|
56
|
+
"refresh_token": self._refresh_token,
|
|
57
|
+
"scope": SCOPE,
|
|
58
|
+
"client_info": "1",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async with self._session.post(f"{AUTH_URL}/token", data=payload) as resp:
|
|
62
|
+
if resp.status != HTTP_OK:
|
|
63
|
+
text = await resp.text()
|
|
64
|
+
_LOGGER.error("Failed to refresh token: %s", text)
|
|
65
|
+
raise DimplexAuthError(f"Failed to refresh token: {resp.status} - {text}")
|
|
66
|
+
|
|
67
|
+
data = await resp.json()
|
|
68
|
+
self._update_tokens(data)
|
|
69
|
+
|
|
70
|
+
def _update_tokens(self, data: dict) -> None:
|
|
71
|
+
"""Update internal token state from API response."""
|
|
72
|
+
self._access_token = data.get("access_token")
|
|
73
|
+
self._refresh_token = data.get("refresh_token")
|
|
74
|
+
expires_in = data.get("expires_in", 3600)
|
|
75
|
+
self._expires_at = time.time() + expires_in - 60 # Buffer 60s
|
|
76
|
+
|
|
77
|
+
def get_login_url(self) -> str:
|
|
78
|
+
"""Generate the login URL for the user to visit."""
|
|
79
|
+
# Note: This is a simplified URL generation.
|
|
80
|
+
# In a real app, we might need state, nonce, code_challenge (PKCE).
|
|
81
|
+
# Based on logs, iOS app uses standard OAuth2.
|
|
82
|
+
# Constructing a URL for manual copy-paste might be tricky if it strictly requires a custom scheme redirect.
|
|
83
|
+
# But we can try the standard authorize endpoint.
|
|
84
|
+
|
|
85
|
+
params = {
|
|
86
|
+
"client_id": CLIENT_ID,
|
|
87
|
+
"response_type": "code",
|
|
88
|
+
"redirect_uri": REDIRECT_URI,
|
|
89
|
+
"scope": SCOPE,
|
|
90
|
+
"response_mode": "query",
|
|
91
|
+
}
|
|
92
|
+
from urllib.parse import urlencode
|
|
93
|
+
|
|
94
|
+
return f"{AUTH_URL}/authorize?{urlencode(params)}"
|
|
95
|
+
|
|
96
|
+
async def exchange_code(self, code: str) -> None:
|
|
97
|
+
"""Exchange authorization code for tokens."""
|
|
98
|
+
payload = {
|
|
99
|
+
"client_id": CLIENT_ID,
|
|
100
|
+
"grant_type": "authorization_code",
|
|
101
|
+
"code": code,
|
|
102
|
+
"redirect_uri": REDIRECT_URI,
|
|
103
|
+
"scope": SCOPE,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
_LOGGER.info(f"Exchanging code for tokens at {AUTH_URL}/token")
|
|
107
|
+
client_id = payload["client_id"]
|
|
108
|
+
redirect_uri = payload["redirect_uri"]
|
|
109
|
+
code_preview = code[:10]
|
|
110
|
+
_LOGGER.info(f"Payload: client_id={client_id}, redirect_uri={redirect_uri}, " f"code={code_preview}...")
|
|
111
|
+
|
|
112
|
+
async with self._session.post(f"{AUTH_URL}/token", data=payload) as resp:
|
|
113
|
+
_LOGGER.info(f"Token exchange response status: {resp.status}")
|
|
114
|
+
if resp.status != HTTP_OK:
|
|
115
|
+
text = await resp.text()
|
|
116
|
+
_LOGGER.error(f"Token exchange failed: {text}")
|
|
117
|
+
raise DimplexAuthError(f"Failed to exchange code: {text}")
|
|
118
|
+
|
|
119
|
+
data = await resp.json()
|
|
120
|
+
self._update_tokens(data)
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def _build_cookie_header(cookie_jar, url: str) -> str:
|
|
124
|
+
"""Build an unquoted Cookie header from an aiohttp cookie jar.
|
|
125
|
+
|
|
126
|
+
Python's http.cookies wraps values containing +, /, or = in
|
|
127
|
+
double-quotes, but Azure AD B2C expects raw unquoted values.
|
|
128
|
+
"""
|
|
129
|
+
filtered = cookie_jar.filter_cookies(url)
|
|
130
|
+
return "; ".join(f"{m.key}={m.value}" for m in filtered.values())
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def _parse_b2c_login_page(html: str, page_url: str) -> dict:
|
|
134
|
+
"""Extract B2C form fields from the login page HTML.
|
|
135
|
+
|
|
136
|
+
Returns a dict with csrf, tx, p, post_url, confirmed_url.
|
|
137
|
+
Raises DimplexAuthError if required fields cannot be found.
|
|
138
|
+
"""
|
|
139
|
+
csrf_match = re.search(r'"csrf"\s*:\s*"([^"]+)"', html)
|
|
140
|
+
if not csrf_match:
|
|
141
|
+
raise DimplexAuthError("Could not find CSRF token in B2C login page")
|
|
142
|
+
csrf = csrf_match.group(1)
|
|
143
|
+
|
|
144
|
+
tx_match = re.search(r'"transId"\s*:\s*"([^"]+)"', html)
|
|
145
|
+
if not tx_match:
|
|
146
|
+
raise DimplexAuthError("Could not find transId in B2C login page")
|
|
147
|
+
tx = tx_match.group(1)
|
|
148
|
+
|
|
149
|
+
# Build base URL by stripping the authorize endpoint.
|
|
150
|
+
# The B2C login page URL contains /tfp/{tenant}/{policy}/oauth2/v2.0/authorize
|
|
151
|
+
# and may redirect to /{tenant}/{policy}/oauth2/v2.0/authorize
|
|
152
|
+
if "/oauth2/v2.0/authorize" in page_url:
|
|
153
|
+
base_url = page_url.split("/oauth2/v2.0/authorize")[0]
|
|
154
|
+
else:
|
|
155
|
+
parsed = urlparse(page_url)
|
|
156
|
+
base_url = f"{parsed.scheme}://{parsed.netloc}" f"/gdhvb2c.onmicrosoft.com/{B2C_POLICY}"
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
"csrf": csrf,
|
|
160
|
+
"tx": tx,
|
|
161
|
+
"p": B2C_POLICY,
|
|
162
|
+
"post_url": f"{base_url}/SelfAsserted?tx={tx}&p={B2C_POLICY}",
|
|
163
|
+
"confirmed_url": (f"{base_url}/api/CombinedSigninAndSignup/confirmed"),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async def headless_login(self, email: str, password: str) -> None:
|
|
167
|
+
"""Perform a headless login via Azure AD B2C to obtain tokens.
|
|
168
|
+
|
|
169
|
+
Uses direct HTTP credential submission so users don't need to
|
|
170
|
+
manually extract auth codes from browser network traffic.
|
|
171
|
+
"""
|
|
172
|
+
jar = aiohttp.CookieJar(unsafe=True)
|
|
173
|
+
start_url = self.get_login_url()
|
|
174
|
+
|
|
175
|
+
async with aiohttp.ClientSession(cookie_jar=jar) as session:
|
|
176
|
+
# Step 1: GET the auth URI, follow redirects to B2C login page
|
|
177
|
+
_LOGGER.debug("Fetching B2C login page: %s", start_url)
|
|
178
|
+
async with session.get(start_url, allow_redirects=True) as resp:
|
|
179
|
+
login_html = await resp.text()
|
|
180
|
+
page_url = str(resp.url)
|
|
181
|
+
if resp.status != HTTP_OK:
|
|
182
|
+
raise DimplexAuthError(f"B2C login page returned HTTP {resp.status}")
|
|
183
|
+
|
|
184
|
+
# Step 2: Parse the login page for CSRF, transaction ID, policy
|
|
185
|
+
fields = self._parse_b2c_login_page(login_html, page_url)
|
|
186
|
+
_LOGGER.debug(
|
|
187
|
+
"Parsed B2C login page: csrf=%s... tx=%s... p=%s",
|
|
188
|
+
fields["csrf"][:16],
|
|
189
|
+
fields["tx"][:40],
|
|
190
|
+
fields["p"],
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Step 3: POST credentials to SelfAsserted endpoint
|
|
194
|
+
post_data = {
|
|
195
|
+
"request_type": "RESPONSE",
|
|
196
|
+
"email": email,
|
|
197
|
+
"password": password,
|
|
198
|
+
}
|
|
199
|
+
parsed_page = urlparse(page_url)
|
|
200
|
+
origin = f"{parsed_page.scheme}://{parsed_page.netloc}"
|
|
201
|
+
post_headers = {
|
|
202
|
+
"X-CSRF-TOKEN": fields["csrf"],
|
|
203
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
204
|
+
"Referer": page_url,
|
|
205
|
+
"Origin": origin,
|
|
206
|
+
"Accept": "application/json, text/javascript, */*; q=0.01",
|
|
207
|
+
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
# Build an unquoted Cookie header — aiohttp wraps values
|
|
211
|
+
# containing +/= in double-quotes, but B2C requires raw values.
|
|
212
|
+
cookie_header = self._build_cookie_header(jar, fields["post_url"])
|
|
213
|
+
post_headers["Cookie"] = cookie_header
|
|
214
|
+
_LOGGER.debug("Submitting credentials to %s", fields["post_url"])
|
|
215
|
+
|
|
216
|
+
# Use DummyCookieJar so POST response cookies aren't
|
|
217
|
+
# re-injected with quoted values on the next request.
|
|
218
|
+
async with aiohttp.ClientSession(
|
|
219
|
+
cookie_jar=aiohttp.DummyCookieJar(),
|
|
220
|
+
) as raw_session:
|
|
221
|
+
async with raw_session.post(
|
|
222
|
+
fields["post_url"],
|
|
223
|
+
data=post_data,
|
|
224
|
+
headers=post_headers,
|
|
225
|
+
allow_redirects=False,
|
|
226
|
+
) as resp:
|
|
227
|
+
body = await resp.text()
|
|
228
|
+
if resp.status != HTTP_OK:
|
|
229
|
+
raise DimplexAuthError(f"Credential submission returned HTTP {resp.status}")
|
|
230
|
+
try:
|
|
231
|
+
resp_data = json.loads(body)
|
|
232
|
+
if str(resp_data.get("status")) == "400":
|
|
233
|
+
raise DimplexAuthError("Invalid email or password")
|
|
234
|
+
except json.JSONDecodeError:
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
# Merge POST response cookies into the cookie header
|
|
238
|
+
cookies: dict[str, str] = {}
|
|
239
|
+
for part in cookie_header.split("; "):
|
|
240
|
+
if "=" in part:
|
|
241
|
+
n, v = part.split("=", 1)
|
|
242
|
+
cookies[n] = v
|
|
243
|
+
for raw_sc in resp.headers.getall("Set-Cookie", []):
|
|
244
|
+
sc_pair = raw_sc.split(";", 1)[0]
|
|
245
|
+
if "=" in sc_pair:
|
|
246
|
+
n, v = sc_pair.split("=", 1)
|
|
247
|
+
cookies[n] = v
|
|
248
|
+
cookie_header = "; ".join(f"{n}={v}" for n, v in cookies.items())
|
|
249
|
+
|
|
250
|
+
# Step 4: GET the confirmed endpoint and follow redirects
|
|
251
|
+
confirmed_qs = (
|
|
252
|
+
f"rememberMe=false" f"&csrf_token={fields['csrf']}" f"&tx={fields['tx']}" f"&p={fields['p']}"
|
|
253
|
+
)
|
|
254
|
+
next_url: str = fields["confirmed_url"] + "?" + confirmed_qs
|
|
255
|
+
confirmed_headers = {"Cookie": cookie_header}
|
|
256
|
+
|
|
257
|
+
for _ in range(20): # max redirect hops
|
|
258
|
+
_LOGGER.debug("Following redirect: %s", next_url[:120])
|
|
259
|
+
async with raw_session.get(
|
|
260
|
+
next_url,
|
|
261
|
+
headers=confirmed_headers,
|
|
262
|
+
allow_redirects=False,
|
|
263
|
+
) as resp:
|
|
264
|
+
resp_body = await resp.text()
|
|
265
|
+
if resp.status in (301, 302, 303, 307, 308):
|
|
266
|
+
location = resp.headers.get("Location", "")
|
|
267
|
+
if not location:
|
|
268
|
+
raise DimplexAuthError("Redirect without Location header")
|
|
269
|
+
if location.startswith(REDIRECT_URI) and (
|
|
270
|
+
len(location) == len(REDIRECT_URI) or location[len(REDIRECT_URI)] in ("?", "/")
|
|
271
|
+
):
|
|
272
|
+
_LOGGER.debug(
|
|
273
|
+
"Captured redirect with code: %s...",
|
|
274
|
+
location[:120],
|
|
275
|
+
)
|
|
276
|
+
parsed = urlparse(location)
|
|
277
|
+
query = parse_qs(parsed.query)
|
|
278
|
+
code = query.get("code", [""])[0]
|
|
279
|
+
if not code:
|
|
280
|
+
raise DimplexAuthError("Redirect URL missing auth code")
|
|
281
|
+
await self.exchange_code(code)
|
|
282
|
+
return
|
|
283
|
+
if not location.startswith("http"):
|
|
284
|
+
location = (
|
|
285
|
+
f"{parsed_page.scheme}://{parsed_page.netloc}" + location
|
|
286
|
+
if location.startswith("/")
|
|
287
|
+
else location
|
|
288
|
+
)
|
|
289
|
+
next_url = location
|
|
290
|
+
continue
|
|
291
|
+
if resp.status == HTTP_OK:
|
|
292
|
+
redirect_match = re.search(
|
|
293
|
+
rf"({re.escape(REDIRECT_URI)}\?[^\s\"'<]+)",
|
|
294
|
+
resp_body,
|
|
295
|
+
)
|
|
296
|
+
if redirect_match:
|
|
297
|
+
parsed = urlparse(redirect_match.group(1))
|
|
298
|
+
query = parse_qs(parsed.query)
|
|
299
|
+
code = query.get("code", [""])[0]
|
|
300
|
+
if code:
|
|
301
|
+
await self.exchange_code(code)
|
|
302
|
+
return
|
|
303
|
+
raise DimplexAuthError("Reached 200 response without finding redirect URL")
|
|
304
|
+
raise DimplexAuthError(f"Unexpected HTTP {resp.status} during redirect chain")
|
|
305
|
+
|
|
306
|
+
raise DimplexAuthError("Exceeded maximum redirect hops without capturing auth code")
|
|
307
|
+
|
|
308
|
+
def save_tokens(self, file_path: str) -> None:
|
|
309
|
+
"""Save current tokens to a JSON file."""
|
|
310
|
+
data = {
|
|
311
|
+
"access_token": self._access_token,
|
|
312
|
+
"refresh_token": self._refresh_token,
|
|
313
|
+
"expires_at": self._expires_at,
|
|
314
|
+
}
|
|
315
|
+
with open(file_path, "w") as f:
|
|
316
|
+
json.dump(data, f, indent=2)
|
|
317
|
+
_LOGGER.info("Tokens saved to %s", file_path)
|
|
318
|
+
|
|
319
|
+
@classmethod
|
|
320
|
+
def load_tokens(cls, file_path: str) -> dict | None:
|
|
321
|
+
"""Load tokens from a JSON file."""
|
|
322
|
+
if not os.path.exists(file_path):
|
|
323
|
+
return None
|
|
324
|
+
try:
|
|
325
|
+
with open(file_path) as f:
|
|
326
|
+
return json.load(f)
|
|
327
|
+
except Exception as e:
|
|
328
|
+
_LOGGER.error("Failed to load tokens from %s: %s", file_path, e)
|
|
329
|
+
return None
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
from
|
|
2
|
+
from datetime import datetime, timedelta, timezone
|
|
3
3
|
|
|
4
4
|
import aiohttp
|
|
5
5
|
|
|
@@ -21,12 +21,25 @@ from .models import (
|
|
|
21
21
|
ApplianceStatus,
|
|
22
22
|
Hub,
|
|
23
23
|
TimerModeSettings,
|
|
24
|
+
TsiEnergyReport,
|
|
24
25
|
UserContext,
|
|
25
26
|
Zone,
|
|
26
27
|
)
|
|
27
28
|
|
|
28
29
|
_LOGGER = logging.getLogger(__name__)
|
|
29
30
|
|
|
31
|
+
# Default lookback when the caller does not pin a start date. The energy
|
|
32
|
+
# endpoint is paginated server-side; 30 days is a reasonable default that
|
|
33
|
+
# keeps the response small while still giving the caller a meaningful window.
|
|
34
|
+
DEFAULT_TSI_REPORT_DAYS = 30
|
|
35
|
+
DEFAULT_TSI_INTERVAL = "01:00:00"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _iso_utc_days_ago(days: int) -> str:
|
|
39
|
+
"""Return an ISO-8601 UTC timestamp ``days`` before now (no microseconds)."""
|
|
40
|
+
dt = datetime.now(timezone.utc) - timedelta(days=days)
|
|
41
|
+
return dt.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
42
|
+
|
|
30
43
|
|
|
31
44
|
class DimplexControl:
|
|
32
45
|
"""Main client for Dimplex Control API."""
|
|
@@ -34,8 +47,8 @@ class DimplexControl:
|
|
|
34
47
|
def __init__(
|
|
35
48
|
self,
|
|
36
49
|
session: aiohttp.ClientSession,
|
|
37
|
-
refresh_token:
|
|
38
|
-
access_token:
|
|
50
|
+
refresh_token: str | None = None,
|
|
51
|
+
access_token: str | None = None,
|
|
39
52
|
expires_at: float = 0,
|
|
40
53
|
):
|
|
41
54
|
"""Initialize the client."""
|
|
@@ -45,7 +58,7 @@ class DimplexControl:
|
|
|
45
58
|
if access_token:
|
|
46
59
|
token_data["access_token"] = access_token
|
|
47
60
|
if expires_at:
|
|
48
|
-
token_data["expires_at"] = expires_at
|
|
61
|
+
token_data["expires_at"] = expires_at # type: ignore[assignment]
|
|
49
62
|
|
|
50
63
|
self._session = session
|
|
51
64
|
self.auth = AuthManager(session, token_data)
|
|
@@ -55,7 +68,7 @@ class DimplexControl:
|
|
|
55
68
|
"""Check if authenticated."""
|
|
56
69
|
return self.auth.is_authenticated
|
|
57
70
|
|
|
58
|
-
async def _request(self, method: str, endpoint: str, **kwargs) ->
|
|
71
|
+
async def _request(self, method: str, endpoint: str, **kwargs) -> dict:
|
|
59
72
|
"""Make an authenticated request."""
|
|
60
73
|
token = await self.auth.get_access_token()
|
|
61
74
|
headers = kwargs.pop("headers", {})
|
|
@@ -92,13 +105,13 @@ class DimplexControl:
|
|
|
92
105
|
_LOGGER.error("Connection error during API request: %s", e)
|
|
93
106
|
raise DimplexConnectionError(f"Connection error: {e}") from e
|
|
94
107
|
|
|
95
|
-
async def get_hubs(self) ->
|
|
108
|
+
async def get_hubs(self) -> list[Hub]:
|
|
96
109
|
"""Get all hubs for the user."""
|
|
97
110
|
data = await self._request("GET", "/Hubs/GetUserHubs")
|
|
98
111
|
# Log analysis shows list of objects
|
|
99
112
|
return [Hub(**h) for h in data]
|
|
100
113
|
|
|
101
|
-
async def get_hub_zones(self, hub_id: str) ->
|
|
114
|
+
async def get_hub_zones(self, hub_id: str) -> list[Zone]:
|
|
102
115
|
"""Get zones and appliances for a hub."""
|
|
103
116
|
data = await self._request("GET", "/Zones/GetZonesAndAppliancesForHubId", params={"HubId": hub_id})
|
|
104
117
|
return [Zone(**z) for z in data]
|
|
@@ -109,7 +122,7 @@ class DimplexControl:
|
|
|
109
122
|
data = await self._request("POST", "/Zones/GetZone", json=payload)
|
|
110
123
|
return Zone(**data)
|
|
111
124
|
|
|
112
|
-
async def get_appliance_overview(self, hub_id: str, appliance_ids:
|
|
125
|
+
async def get_appliance_overview(self, hub_id: str, appliance_ids: list[str]) -> list[ApplianceStatus]:
|
|
113
126
|
"""Get status overview for specific appliances."""
|
|
114
127
|
payload = {"HubId": hub_id, "ApplianceIds": appliance_ids}
|
|
115
128
|
data = await self._request("POST", "/RemoteControl/GetApplianceOverview", json=payload)
|
|
@@ -159,18 +172,55 @@ class DimplexControl:
|
|
|
159
172
|
pass
|
|
160
173
|
|
|
161
174
|
async def set_appliance_mode(
|
|
162
|
-
self, hub_id: str, appliance_ids:
|
|
175
|
+
self, hub_id: str, appliance_ids: list[str], mode_settings: ApplianceModeSettings
|
|
163
176
|
) -> None:
|
|
164
177
|
"""Set appliance mode (Boost, Away, etc.)."""
|
|
165
178
|
payload = {"Settings": mode_settings.dict(), "HubId": hub_id, "ApplianceIds": appliance_ids}
|
|
166
179
|
await self._request("POST", "/RemoteControl/SetApplianceMode", json=payload)
|
|
167
180
|
|
|
168
|
-
async def set_eco_start(self, hub_id: str, appliance_ids:
|
|
181
|
+
async def set_eco_start(self, hub_id: str, appliance_ids: list[str], enable: bool) -> None:
|
|
169
182
|
"""Enable/Disable EcoStart."""
|
|
170
183
|
payload = {"Enable": enable, "HubId": hub_id, "ApplianceIds": appliance_ids}
|
|
171
184
|
await self._request("POST", "/RemoteControl/SetEcoStart", json=payload)
|
|
172
185
|
|
|
173
|
-
async def set_open_window_detection(self, hub_id: str, appliance_ids:
|
|
186
|
+
async def set_open_window_detection(self, hub_id: str, appliance_ids: list[str], enable: bool) -> None:
|
|
174
187
|
"""Enable/Disable Open Window Detection."""
|
|
175
188
|
payload = {"Enable": enable, "HubId": hub_id, "ApplianceIds": appliance_ids}
|
|
176
189
|
await self._request("POST", "/RemoteControl/SetOpenWindowDetection", json=payload)
|
|
190
|
+
|
|
191
|
+
async def get_tsi_energy_report(
|
|
192
|
+
self,
|
|
193
|
+
hub_id: str | None = None,
|
|
194
|
+
report_type: int = 1,
|
|
195
|
+
interval: str = DEFAULT_TSI_INTERVAL,
|
|
196
|
+
start_date: str | None = None,
|
|
197
|
+
include_previous_period: bool = False,
|
|
198
|
+
days_back: int = DEFAULT_TSI_REPORT_DAYS,
|
|
199
|
+
) -> TsiEnergyReport:
|
|
200
|
+
"""Fetch the Time Series Insights energy report for a hub.
|
|
201
|
+
|
|
202
|
+
Returns a :class:`~dimplex_controller.models.TsiEnergyReport`. The
|
|
203
|
+
cloud response is the same regardless of ``hub_id`` (the field is
|
|
204
|
+
informational), so ``hub_id`` is only required to populate the
|
|
205
|
+
returned model. Each per-appliance list is left as the raw payload —
|
|
206
|
+
use :func:`dimplex_controller.telemetry.parse_telemetry_points` to
|
|
207
|
+
normalise the points into ``(timestamp, value)`` tuples.
|
|
208
|
+
|
|
209
|
+
When the hub has no metered appliances (e.g. non-QRAD heaters, or a
|
|
210
|
+
quiet summer hub) the per-appliance lists come back empty; that is
|
|
211
|
+
treated as success, not an error.
|
|
212
|
+
"""
|
|
213
|
+
if start_date is None:
|
|
214
|
+
start_date = _iso_utc_days_ago(days_back)
|
|
215
|
+
|
|
216
|
+
payload = {
|
|
217
|
+
"TsiReportType": report_type,
|
|
218
|
+
"Interval": interval,
|
|
219
|
+
"StartDate": start_date,
|
|
220
|
+
"IncludePreviousPeriod": include_previous_period,
|
|
221
|
+
}
|
|
222
|
+
data = await self._request("POST", "/Reports/GetTsiEnergyReportDataForHub", json=payload)
|
|
223
|
+
return TsiEnergyReport(
|
|
224
|
+
HubId=(hub_id or data.get("HubId", "")),
|
|
225
|
+
ApplianceTelemetryData=data.get("ApplianceTelemetryData", {}) or {},
|
|
226
|
+
)
|
|
@@ -19,3 +19,4 @@ HEADER_DEVICE_MODEL = "iPhone18,1"
|
|
|
19
19
|
CLIENT_ID = "6c983ca3-506e-4933-8993-0e18e6a24bbd"
|
|
20
20
|
SCOPE = "https://gdhvb2c.onmicrosoft.com/Mobile/read offline_access openid profile"
|
|
21
21
|
REDIRECT_URI = "msal6c983ca3-506e-4933-8993-0e18e6a24bbd://auth/"
|
|
22
|
+
B2C_POLICY = "B2C_1A_DimplexControlSignupSignin"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from datetime import datetime, time
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Appliance(BaseModel):
|
|
7
|
+
ApplianceId: str
|
|
8
|
+
ApplianceType: str
|
|
9
|
+
ApplianceModel: str | None = None
|
|
10
|
+
ZoneId: str
|
|
11
|
+
FriendlyName: str
|
|
12
|
+
ZoneName: str
|
|
13
|
+
Icon: str | None = None
|
|
14
|
+
IconColor: str | None = None
|
|
15
|
+
InstallationDate: datetime | None = None
|
|
16
|
+
HasConnectivity: bool | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Zone(BaseModel):
|
|
20
|
+
ZoneId: str
|
|
21
|
+
ZoneName: str
|
|
22
|
+
HubId: str
|
|
23
|
+
ZoneType: str
|
|
24
|
+
Appliances: list[Appliance] = Field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Hub(BaseModel):
|
|
28
|
+
HubId: str
|
|
29
|
+
Name: str | None = Field(None, alias="HubName")
|
|
30
|
+
FriendlyName: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TimerPeriod(BaseModel):
|
|
34
|
+
DayOfWeek: int
|
|
35
|
+
StartTime: str # Kept as str for easy JSON serialization
|
|
36
|
+
EndTime: str
|
|
37
|
+
Temperature: float
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def start_time_obj(self) -> time:
|
|
41
|
+
return datetime.strptime(self.StartTime, "%H:%M:%S").time()
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def end_time_obj(self) -> time:
|
|
45
|
+
return datetime.strptime(self.EndTime, "%H:%M:%S").time()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TimerModeSettings(BaseModel):
|
|
49
|
+
HubId: str
|
|
50
|
+
ApplianceId: str
|
|
51
|
+
TimerMode: int
|
|
52
|
+
TimerPeriods: list[TimerPeriod] = Field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class UserContext(BaseModel):
|
|
56
|
+
Id: str
|
|
57
|
+
EmailAddress: str | None = None
|
|
58
|
+
Name: str | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ApplianceStatus(BaseModel):
|
|
62
|
+
"""Represents the real-time status of an appliance as returned by GetApplianceOverview."""
|
|
63
|
+
|
|
64
|
+
HubId: str
|
|
65
|
+
ApplianceId: str
|
|
66
|
+
ZoneId: str
|
|
67
|
+
StatusTwo: int | None = None
|
|
68
|
+
ApplianceModes: int | None = None
|
|
69
|
+
RoomTemperature: float | None = None
|
|
70
|
+
ActiveSetPointTemperature: int | None = None
|
|
71
|
+
NormalTemperature: float | None = None
|
|
72
|
+
AwayDateTime: str | None = None
|
|
73
|
+
AwayTemperature: float | None = None
|
|
74
|
+
BoostDuration: int | None = None
|
|
75
|
+
BoostTemperature: float | None = None
|
|
76
|
+
OpenWindowEnabled: bool | None = None
|
|
77
|
+
EcoStartEnabled: bool | None = None
|
|
78
|
+
SetbackEnabled: bool | None = None
|
|
79
|
+
SetbackEnabledInStatusFrame: bool | None = None
|
|
80
|
+
SetbackTemperature: float | None = None
|
|
81
|
+
ComfortStatus: bool | None = None
|
|
82
|
+
AvailableHotWater: float | None = None
|
|
83
|
+
LockStatus: int | None = None
|
|
84
|
+
ErrorCode: str | None = None
|
|
85
|
+
WarningCode: str | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ApplianceModeSettings(BaseModel):
|
|
89
|
+
"""Settings used to control appliance modes like Boost or Away."""
|
|
90
|
+
|
|
91
|
+
ApplianceModes: int
|
|
92
|
+
Status: int
|
|
93
|
+
Temperature: float = 23.0
|
|
94
|
+
Time: int = 0
|
|
95
|
+
Date: str = "0001-01-01T00:00:00"
|
|
96
|
+
StatusTwo: int = 0
|
|
97
|
+
NumberOfDays: int = 0
|
|
98
|
+
Frequency: int = 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TsiEnergyReport(BaseModel):
|
|
102
|
+
"""Response from `POST /Reports/GetTsiEnergyReportDataForHub`.
|
|
103
|
+
|
|
104
|
+
The cloud returns one telemetry bucket per appliance registered to the hub.
|
|
105
|
+
Each bucket is a list of points whose individual shape is undocumented and
|
|
106
|
+
appears to vary by firmware; entries are normalised by
|
|
107
|
+
:func:`dimplex_controller.telemetry.parse_telemetry_points` so callers do
|
|
108
|
+
not have to know the wire format.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
HubId: str
|
|
112
|
+
ApplianceTelemetryData: dict[str, list] = Field(default_factory=dict)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# `TsiReportType` integer values understood by the API. We do not know the
|
|
116
|
+
# canonical mapping; these are observed in traffic captures.
|
|
117
|
+
TSI_REPORT_TYPE_ENERGY = 1
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Telemetry parsing for the Dimplex Reports API.
|
|
2
|
+
|
|
3
|
+
The response from `POST /Reports/GetTsiEnergyReportDataForHub` contains
|
|
4
|
+
``ApplianceTelemetryData``: a dict keyed by appliance id, with one list of
|
|
5
|
+
``telemetry points`` per appliance. The shape of each point is not documented
|
|
6
|
+
and varies between firmware versions, so we normalise whatever the cloud
|
|
7
|
+
sends into ``(timestamp, value)`` tuples.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
# Keys the cloud has been observed using, in priority order. The first match
|
|
17
|
+
# wins. Case-insensitive lookup is done by lowercasing the dict before
|
|
18
|
+
# scanning, so callers do not have to worry about casing.
|
|
19
|
+
_TIMESTAMP_KEYS = (
|
|
20
|
+
"timestamp",
|
|
21
|
+
"time",
|
|
22
|
+
"ts",
|
|
23
|
+
"t",
|
|
24
|
+
"datetime",
|
|
25
|
+
"date",
|
|
26
|
+
"from",
|
|
27
|
+
"start",
|
|
28
|
+
)
|
|
29
|
+
_VALUE_KEYS = (
|
|
30
|
+
"value",
|
|
31
|
+
"kwh",
|
|
32
|
+
"energy",
|
|
33
|
+
"consumption",
|
|
34
|
+
"energykwh",
|
|
35
|
+
"amount",
|
|
36
|
+
"v",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _coerce_timestamp(raw: Any) -> datetime | None:
|
|
41
|
+
"""Best-effort conversion of ``raw`` into a ``datetime``.
|
|
42
|
+
|
|
43
|
+
Accepts ``datetime`` instances, ISO-8601 strings, and Unix epoch numbers
|
|
44
|
+
(seconds or milliseconds). Returns ``None`` if the value cannot be parsed.
|
|
45
|
+
"""
|
|
46
|
+
if raw is None:
|
|
47
|
+
return None
|
|
48
|
+
if isinstance(raw, datetime):
|
|
49
|
+
return raw
|
|
50
|
+
if isinstance(raw, int | float):
|
|
51
|
+
ts = float(raw)
|
|
52
|
+
if ts > 1e12:
|
|
53
|
+
ts = ts / 1000.0
|
|
54
|
+
try:
|
|
55
|
+
return datetime.fromtimestamp(ts, tz=timezone.utc)
|
|
56
|
+
except (OverflowError, OSError, ValueError):
|
|
57
|
+
return None
|
|
58
|
+
if isinstance(raw, str):
|
|
59
|
+
text = raw.strip()
|
|
60
|
+
if not text:
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
64
|
+
except ValueError:
|
|
65
|
+
return None
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _coerce_value(raw: Any) -> float | None:
|
|
70
|
+
"""Best-effort conversion of ``raw`` into a ``float``."""
|
|
71
|
+
if raw is None:
|
|
72
|
+
return None
|
|
73
|
+
if isinstance(raw, bool):
|
|
74
|
+
return None
|
|
75
|
+
if isinstance(raw, int | float):
|
|
76
|
+
return float(raw)
|
|
77
|
+
if isinstance(raw, str):
|
|
78
|
+
try:
|
|
79
|
+
return float(raw.strip())
|
|
80
|
+
except ValueError:
|
|
81
|
+
return None
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _iter_items(point: Any) -> Iterable[tuple[str, Any]] | None:
|
|
86
|
+
"""Yield ``(key, value)`` pairs from a dict-like ``point``.
|
|
87
|
+
|
|
88
|
+
Returns ``None`` for non-dict points so the caller can fall through to the
|
|
89
|
+
list / scalar branches.
|
|
90
|
+
"""
|
|
91
|
+
if not isinstance(point, dict):
|
|
92
|
+
return None
|
|
93
|
+
lower = {str(k).lower(): v for k, v in point.items()}
|
|
94
|
+
return ((k, lower.get(k)) for k in (*_TIMESTAMP_KEYS, *_VALUE_KEYS))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parse_telemetry_points(points: Any) -> list[tuple[datetime | None, float]]:
|
|
98
|
+
"""Normalise a list of telemetry points into ``(timestamp, value)`` pairs.
|
|
99
|
+
|
|
100
|
+
Each entry in ``points`` may be:
|
|
101
|
+
|
|
102
|
+
* a ``dict`` with one of the recognised timestamp/value keys
|
|
103
|
+
* a 2-element ``[timestamp, value]`` list or tuple
|
|
104
|
+
* a bare scalar (treated as a cumulative value at an unknown timestamp)
|
|
105
|
+
|
|
106
|
+
Unparseable entries are silently skipped. The order of the input list is
|
|
107
|
+
preserved.
|
|
108
|
+
"""
|
|
109
|
+
if not isinstance(points, list):
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
out: list[tuple[datetime | None, float]] = []
|
|
113
|
+
for point in points:
|
|
114
|
+
ts: datetime | None = None
|
|
115
|
+
value: float | None = None
|
|
116
|
+
|
|
117
|
+
items = _iter_items(point)
|
|
118
|
+
if items is not None:
|
|
119
|
+
for key, raw in items:
|
|
120
|
+
if key in _TIMESTAMP_KEYS and ts is None:
|
|
121
|
+
ts = _coerce_timestamp(raw)
|
|
122
|
+
elif key in _VALUE_KEYS and value is None:
|
|
123
|
+
value = _coerce_value(raw)
|
|
124
|
+
elif isinstance(point, list | tuple) and len(point) == 2:
|
|
125
|
+
ts = _coerce_timestamp(point[0])
|
|
126
|
+
value = _coerce_value(point[1])
|
|
127
|
+
else:
|
|
128
|
+
value = _coerce_value(point)
|
|
129
|
+
|
|
130
|
+
if value is None:
|
|
131
|
+
continue
|
|
132
|
+
out.append((ts, value))
|
|
133
|
+
return out
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "dimplex-controller"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "Python client for Dimplex heating controllers (GDHV IoT)"
|
|
5
5
|
authors = ["Kieran Roper"]
|
|
6
6
|
license = "MIT"
|
|
@@ -18,6 +18,7 @@ classifiers = [
|
|
|
18
18
|
"Programming Language :: Python :: 3.10",
|
|
19
19
|
"Programming Language :: Python :: 3.11",
|
|
20
20
|
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
21
22
|
"Topic :: Home Automation",
|
|
22
23
|
]
|
|
23
24
|
packages = [{ include = "dimplex_controller" }]
|
|
@@ -31,6 +32,7 @@ python-dotenv = "^1.2.1"
|
|
|
31
32
|
[tool.poetry.group.dev.dependencies]
|
|
32
33
|
pytest = "^8.0.0"
|
|
33
34
|
pytest-asyncio = "^0.23.0"
|
|
35
|
+
pytest-cov = "^6.0.0"
|
|
34
36
|
aresponses = "^3.0.0"
|
|
35
37
|
ruff = "^0.3.0"
|
|
36
38
|
pre-commit = "^3.6.0"
|
|
@@ -41,7 +43,18 @@ line-length = 120
|
|
|
41
43
|
target-version = "py310"
|
|
42
44
|
|
|
43
45
|
[tool.ruff.lint]
|
|
44
|
-
select = ["E", "F", "I", "W"]
|
|
46
|
+
select = ["E", "F", "I", "W", "UP", "B", "SIM"]
|
|
47
|
+
# E501 (line length) is handled by the formatter; long strings/comments are fine.
|
|
48
|
+
ignore = ["E501"]
|
|
49
|
+
|
|
50
|
+
[tool.mypy]
|
|
51
|
+
# Loose by design — the library is mostly untyped and a strict pass is out of
|
|
52
|
+
# scope for the CI modernisation. The two auth.py return-narrowing issues and
|
|
53
|
+
# the client.py expires_at assignment are pre-existing and either fixed in
|
|
54
|
+
# place or # type: ignore-d; remove when a full typing pass lands.
|
|
55
|
+
python_version = "3.10"
|
|
56
|
+
files = ["dimplex_controller"]
|
|
57
|
+
ignore_missing_imports = true
|
|
45
58
|
|
|
46
59
|
[build-system]
|
|
47
60
|
requires = ["poetry-core"]
|
|
@@ -1,304 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import logging
|
|
3
|
-
import os
|
|
4
|
-
import re
|
|
5
|
-
import time
|
|
6
|
-
from typing import Dict, Optional
|
|
7
|
-
from urllib.parse import parse_qs, urlencode, urlparse
|
|
8
|
-
|
|
9
|
-
import aiohttp
|
|
10
|
-
from bs4 import BeautifulSoup
|
|
11
|
-
|
|
12
|
-
from .const import (
|
|
13
|
-
AUTH_URL,
|
|
14
|
-
CLIENT_ID,
|
|
15
|
-
HTTP_OK,
|
|
16
|
-
REDIRECT_URI,
|
|
17
|
-
SCOPE,
|
|
18
|
-
)
|
|
19
|
-
from .exceptions import DimplexAuthError
|
|
20
|
-
|
|
21
|
-
_LOGGER = logging.getLogger(__name__)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class AuthManager:
|
|
25
|
-
"""Manages authentication for Dimplex Control."""
|
|
26
|
-
|
|
27
|
-
def __init__(self, session: aiohttp.ClientSession, token_data: Optional[Dict] = None):
|
|
28
|
-
"""Initialize the auth manager."""
|
|
29
|
-
self._session = session
|
|
30
|
-
self._access_token: Optional[str] = token_data.get("access_token") if token_data else None
|
|
31
|
-
self._refresh_token: Optional[str] = token_data.get("refresh_token") if token_data else None
|
|
32
|
-
self._expires_at: float = token_data.get("expires_at", 0) if token_data else 0
|
|
33
|
-
|
|
34
|
-
@property
|
|
35
|
-
def is_authenticated(self) -> bool:
|
|
36
|
-
"""Check if we have a valid access token."""
|
|
37
|
-
return self._access_token is not None and time.time() < self._expires_at
|
|
38
|
-
|
|
39
|
-
async def get_access_token(self) -> str:
|
|
40
|
-
"""Get a valid access token, refreshing if necessary."""
|
|
41
|
-
if not self._refresh_token:
|
|
42
|
-
raise DimplexAuthError("No refresh token available. User must authenticate first.")
|
|
43
|
-
|
|
44
|
-
if self.is_authenticated:
|
|
45
|
-
return self._access_token
|
|
46
|
-
|
|
47
|
-
# Token expired or missing, try refresh
|
|
48
|
-
await self.refresh_tokens()
|
|
49
|
-
return self._access_token
|
|
50
|
-
|
|
51
|
-
async def refresh_tokens(self) -> None:
|
|
52
|
-
"""Refresh the access token using the refresh token."""
|
|
53
|
-
_LOGGER.debug("Refreshing access token")
|
|
54
|
-
payload = {
|
|
55
|
-
"client_id": CLIENT_ID,
|
|
56
|
-
"grant_type": "refresh_token",
|
|
57
|
-
"refresh_token": self._refresh_token,
|
|
58
|
-
"scope": SCOPE,
|
|
59
|
-
"client_info": "1",
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
async with self._session.post(f"{AUTH_URL}/token", data=payload) as resp:
|
|
63
|
-
if resp.status != HTTP_OK:
|
|
64
|
-
text = await resp.text()
|
|
65
|
-
_LOGGER.error("Failed to refresh token: %s", text)
|
|
66
|
-
raise DimplexAuthError(f"Failed to refresh token: {resp.status} - {text}")
|
|
67
|
-
|
|
68
|
-
data = await resp.json()
|
|
69
|
-
self._update_tokens(data)
|
|
70
|
-
|
|
71
|
-
def _update_tokens(self, data: Dict) -> None:
|
|
72
|
-
"""Update internal token state from API response."""
|
|
73
|
-
self._access_token = data.get("access_token")
|
|
74
|
-
self._refresh_token = data.get("refresh_token")
|
|
75
|
-
expires_in = data.get("expires_in", 3600)
|
|
76
|
-
self._expires_at = time.time() + expires_in - 60 # Buffer 60s
|
|
77
|
-
|
|
78
|
-
def get_login_url(self) -> str:
|
|
79
|
-
"""Generate the login URL for the user to visit."""
|
|
80
|
-
# Note: This is a simplified URL generation.
|
|
81
|
-
# In a real app, we might need state, nonce, code_challenge (PKCE).
|
|
82
|
-
# Based on logs, iOS app uses standard OAuth2.
|
|
83
|
-
# Constructing a URL for manual copy-paste might be tricky if it strictly requires a custom scheme redirect.
|
|
84
|
-
# But we can try the standard authorize endpoint.
|
|
85
|
-
|
|
86
|
-
params = {
|
|
87
|
-
"client_id": CLIENT_ID,
|
|
88
|
-
"response_type": "code",
|
|
89
|
-
"redirect_uri": REDIRECT_URI,
|
|
90
|
-
"scope": SCOPE,
|
|
91
|
-
"response_mode": "query",
|
|
92
|
-
}
|
|
93
|
-
from urllib.parse import urlencode
|
|
94
|
-
|
|
95
|
-
return f"{AUTH_URL}/authorize?{urlencode(params)}"
|
|
96
|
-
|
|
97
|
-
async def exchange_code(self, code: str) -> None:
|
|
98
|
-
"""Exchange authorization code for tokens."""
|
|
99
|
-
payload = {
|
|
100
|
-
"client_id": CLIENT_ID,
|
|
101
|
-
"grant_type": "authorization_code",
|
|
102
|
-
"code": code,
|
|
103
|
-
"redirect_uri": REDIRECT_URI,
|
|
104
|
-
"scope": SCOPE,
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
_LOGGER.info(f"Exchanging code for tokens at {AUTH_URL}/token")
|
|
108
|
-
client_id = payload["client_id"]
|
|
109
|
-
redirect_uri = payload["redirect_uri"]
|
|
110
|
-
code_preview = code[:10]
|
|
111
|
-
_LOGGER.info(f"Payload: client_id={client_id}, redirect_uri={redirect_uri}, " f"code={code_preview}...")
|
|
112
|
-
|
|
113
|
-
async with self._session.post(f"{AUTH_URL}/token", data=payload) as resp:
|
|
114
|
-
_LOGGER.info(f"Token exchange response status: {resp.status}")
|
|
115
|
-
if resp.status != HTTP_OK:
|
|
116
|
-
text = await resp.text()
|
|
117
|
-
_LOGGER.error(f"Token exchange failed: {text}")
|
|
118
|
-
raise DimplexAuthError(f"Failed to exchange code: {text}")
|
|
119
|
-
|
|
120
|
-
data = await resp.json()
|
|
121
|
-
self._update_tokens(data)
|
|
122
|
-
|
|
123
|
-
async def headless_login(self, email, password) -> None:
|
|
124
|
-
"""Perform a headless login to obtain tokens."""
|
|
125
|
-
# 1. Get the login page
|
|
126
|
-
params = {
|
|
127
|
-
"client_id": CLIENT_ID,
|
|
128
|
-
"response_type": "code",
|
|
129
|
-
"redirect_uri": REDIRECT_URI,
|
|
130
|
-
"scope": SCOPE,
|
|
131
|
-
"response_mode": "query",
|
|
132
|
-
}
|
|
133
|
-
start_url = f"{AUTH_URL}/authorize?{urlencode(params)}"
|
|
134
|
-
_LOGGER.debug(f"Fetching login page: {start_url}")
|
|
135
|
-
|
|
136
|
-
async with self._session.get(start_url) as resp:
|
|
137
|
-
html = await resp.text()
|
|
138
|
-
final_url = str(resp.url)
|
|
139
|
-
|
|
140
|
-
# 2. Extract SETTINGS and CSRF
|
|
141
|
-
match = re.search(r"SETTINGS\s*=\s*({.*?});", html, re.DOTALL | re.MULTILINE)
|
|
142
|
-
if not match:
|
|
143
|
-
raise DimplexAuthError("Could not find SETTINGS in login page")
|
|
144
|
-
|
|
145
|
-
try:
|
|
146
|
-
settings = json.loads(match.group(1))
|
|
147
|
-
except json.JSONDecodeError:
|
|
148
|
-
raise DimplexAuthError("Failed to parse SETTINGS JSON from login page")
|
|
149
|
-
|
|
150
|
-
csrf_token = settings.get("csrf")
|
|
151
|
-
trans_id = settings.get("transId")
|
|
152
|
-
|
|
153
|
-
if not csrf_token or not trans_id:
|
|
154
|
-
raise DimplexAuthError("Missing csrf or transId in login page SETTINGS")
|
|
155
|
-
|
|
156
|
-
# 3. Construct SelfAsserted URL
|
|
157
|
-
if "/oauth2/v2.0/authorize" in final_url:
|
|
158
|
-
base_url = final_url.split("/oauth2/v2.0/authorize")[0]
|
|
159
|
-
else:
|
|
160
|
-
base_url = "https://gdhvb2c.b2clogin.com/gdhvb2c.onmicrosoft.com/B2C_1A_DimplexControlSignupSignin"
|
|
161
|
-
|
|
162
|
-
post_url = f"{base_url}/SelfAsserted"
|
|
163
|
-
|
|
164
|
-
params = {
|
|
165
|
-
"tx": trans_id,
|
|
166
|
-
"p": "B2C_1A_DimplexControlSignupSignin",
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
post_url_with_params = f"{post_url}?{urlencode(params)}"
|
|
170
|
-
|
|
171
|
-
# 4. Submit Credentials
|
|
172
|
-
# Extract hidden fields from the form to ensure we aren't missing anything
|
|
173
|
-
soup = BeautifulSoup(html, "html.parser")
|
|
174
|
-
form = soup.find("form", {"id": "localAccountForm"}) or soup.find("form")
|
|
175
|
-
|
|
176
|
-
form_data = {}
|
|
177
|
-
if form:
|
|
178
|
-
for input_tag in form.find_all("input"):
|
|
179
|
-
name = input_tag.get("name")
|
|
180
|
-
value = input_tag.get("value", "")
|
|
181
|
-
if name:
|
|
182
|
-
form_data[name] = value
|
|
183
|
-
|
|
184
|
-
# Overwrite with credentials
|
|
185
|
-
form_data.update(
|
|
186
|
-
{
|
|
187
|
-
"request_type": "RESPONSE",
|
|
188
|
-
"email": email,
|
|
189
|
-
"password": password,
|
|
190
|
-
}
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
headers = {
|
|
194
|
-
"X-CSRF-TOKEN": csrf_token,
|
|
195
|
-
"X-Requested-With": "XMLHttpRequest",
|
|
196
|
-
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
197
|
-
"Origin": "https://gdhvb2c.b2clogin.com",
|
|
198
|
-
"Referer": final_url,
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
_LOGGER.debug(f"Submitting credentials to {post_url_with_params}")
|
|
202
|
-
# _LOGGER.debug(f"Form data: {form_data}") # Security risk to log password
|
|
203
|
-
|
|
204
|
-
async with self._session.post(post_url_with_params, data=form_data, headers=headers) as resp:
|
|
205
|
-
resp_text = await resp.text()
|
|
206
|
-
_LOGGER.debug(f"Login response status: {resp.status}")
|
|
207
|
-
|
|
208
|
-
try:
|
|
209
|
-
resp_json = json.loads(resp_text)
|
|
210
|
-
_LOGGER.debug(f"Login response JSON: {resp_json}")
|
|
211
|
-
except json.JSONDecodeError:
|
|
212
|
-
_LOGGER.debug(f"Login response Text: {resp_text}")
|
|
213
|
-
raise DimplexAuthError(f"Login response was not valid JSON: {resp_text[:100]}")
|
|
214
|
-
|
|
215
|
-
if resp_json.get("status") != "200":
|
|
216
|
-
status = resp_json.get("status")
|
|
217
|
-
message = resp_json.get("message") or resp_json.get("reason", "Unknown reason")
|
|
218
|
-
raise DimplexAuthError(f"Login failed: {status} - {message}")
|
|
219
|
-
|
|
220
|
-
# 5. Follow the 'Confirmed' step to get the actual code
|
|
221
|
-
# After a successful SelfAsserted, we usually need to make a GET to
|
|
222
|
-
# the 'CombinedSigninAndSignup' or similar endpoint to finalize the
|
|
223
|
-
# flow and get the redirect to our app with the code.
|
|
224
|
-
# Or, sometimes the SelfAsserted response sets a cookie and we just
|
|
225
|
-
# need to hit the authorize endpoint again.
|
|
226
|
-
|
|
227
|
-
# Let's try hitting the original authorize URL again (or the one we were redirected to).
|
|
228
|
-
# Since cookies are in the session, it should now redirect us to the app with the code.
|
|
229
|
-
|
|
230
|
-
_LOGGER.debug("Credentials accepted. Fetching authorize URL again to get code.")
|
|
231
|
-
|
|
232
|
-
# We need to allow redirects to capture the final msauth:// url
|
|
233
|
-
# aiohttp generic session checks redirects.
|
|
234
|
-
# But since the scheme is custom (msal...), aiohttp might throw an error or stop.
|
|
235
|
-
|
|
236
|
-
try:
|
|
237
|
-
async with self._session.get(start_url, allow_redirects=True) as resp:
|
|
238
|
-
# If we are here, it means we didn't crash on custom scheme yet,
|
|
239
|
-
# or we are at a page that directs us.
|
|
240
|
-
# Check history
|
|
241
|
-
pass
|
|
242
|
-
except aiohttp.ClientError:
|
|
243
|
-
# This might happen if the redirect schema is not http/https
|
|
244
|
-
# We can inspect the error or just capture it from the history if possible
|
|
245
|
-
pass
|
|
246
|
-
except Exception:
|
|
247
|
-
# If it tries to redirect to msal..., it might fail if aiohttp doesn't support it.
|
|
248
|
-
# We can disable redirects and follow manually to catch it.
|
|
249
|
-
pass
|
|
250
|
-
|
|
251
|
-
# Manual redirect following to catch custom scheme
|
|
252
|
-
current_url = start_url
|
|
253
|
-
code = None
|
|
254
|
-
|
|
255
|
-
for _ in range(10): # Max redirects
|
|
256
|
-
async with self._session.get(current_url, allow_redirects=False) as resp:
|
|
257
|
-
if resp.status in (302, 303, 301):
|
|
258
|
-
location = resp.headers.get("Location")
|
|
259
|
-
if not location:
|
|
260
|
-
break
|
|
261
|
-
|
|
262
|
-
if location.startswith(REDIRECT_URI) or "code=" in location:
|
|
263
|
-
# Success!
|
|
264
|
-
_LOGGER.debug(f"Found redirect with code: {location}")
|
|
265
|
-
# Extract code
|
|
266
|
-
parsed = urlparse(location)
|
|
267
|
-
query = parse_qs(parsed.query)
|
|
268
|
-
code = query.get("code", [""])[0]
|
|
269
|
-
break
|
|
270
|
-
|
|
271
|
-
current_url = location
|
|
272
|
-
else:
|
|
273
|
-
break
|
|
274
|
-
|
|
275
|
-
if not code:
|
|
276
|
-
raise DimplexAuthError(
|
|
277
|
-
"Failed to obtain auth code after successful login. Redirect URI might have changed."
|
|
278
|
-
)
|
|
279
|
-
|
|
280
|
-
_LOGGER.debug(f"Got code: {code[:10]}...")
|
|
281
|
-
await self.exchange_code(code)
|
|
282
|
-
|
|
283
|
-
def save_tokens(self, file_path: str) -> None:
|
|
284
|
-
"""Save current tokens to a JSON file."""
|
|
285
|
-
data = {
|
|
286
|
-
"access_token": self._access_token,
|
|
287
|
-
"refresh_token": self._refresh_token,
|
|
288
|
-
"expires_at": self._expires_at,
|
|
289
|
-
}
|
|
290
|
-
with open(file_path, "w") as f:
|
|
291
|
-
json.dump(data, f, indent=2)
|
|
292
|
-
_LOGGER.info("Tokens saved to %s", file_path)
|
|
293
|
-
|
|
294
|
-
@classmethod
|
|
295
|
-
def load_tokens(cls, file_path: str) -> Optional[Dict]:
|
|
296
|
-
"""Load tokens from a JSON file."""
|
|
297
|
-
if not os.path.exists(file_path):
|
|
298
|
-
return None
|
|
299
|
-
try:
|
|
300
|
-
with open(file_path, "r") as f:
|
|
301
|
-
return json.load(f)
|
|
302
|
-
except Exception as e:
|
|
303
|
-
_LOGGER.error("Failed to load tokens from %s: %s", file_path, e)
|
|
304
|
-
return None
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
from datetime import datetime, time
|
|
2
|
-
from typing import List, Optional
|
|
3
|
-
|
|
4
|
-
from pydantic import BaseModel, Field
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class Appliance(BaseModel):
|
|
8
|
-
ApplianceId: str
|
|
9
|
-
ApplianceType: str
|
|
10
|
-
ApplianceModel: Optional[str] = None
|
|
11
|
-
ZoneId: str
|
|
12
|
-
FriendlyName: str
|
|
13
|
-
ZoneName: str
|
|
14
|
-
Icon: Optional[str] = None
|
|
15
|
-
IconColor: Optional[str] = None
|
|
16
|
-
InstallationDate: Optional[datetime] = None
|
|
17
|
-
HasConnectivity: Optional[bool] = None
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class Zone(BaseModel):
|
|
21
|
-
ZoneId: str
|
|
22
|
-
ZoneName: str
|
|
23
|
-
HubId: str
|
|
24
|
-
ZoneType: str
|
|
25
|
-
Appliances: List[Appliance] = Field(default_factory=list)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class Hub(BaseModel):
|
|
29
|
-
HubId: str
|
|
30
|
-
Name: Optional[str] = Field(None, alias="HubName")
|
|
31
|
-
FriendlyName: Optional[str] = None
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class TimerPeriod(BaseModel):
|
|
35
|
-
DayOfWeek: int
|
|
36
|
-
StartTime: str # Kept as str for easy JSON serialization
|
|
37
|
-
EndTime: str
|
|
38
|
-
Temperature: float
|
|
39
|
-
|
|
40
|
-
@property
|
|
41
|
-
def start_time_obj(self) -> time:
|
|
42
|
-
return datetime.strptime(self.StartTime, "%H:%M:%S").time()
|
|
43
|
-
|
|
44
|
-
@property
|
|
45
|
-
def end_time_obj(self) -> time:
|
|
46
|
-
return datetime.strptime(self.EndTime, "%H:%M:%S").time()
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
class TimerModeSettings(BaseModel):
|
|
50
|
-
HubId: str
|
|
51
|
-
ApplianceId: str
|
|
52
|
-
TimerMode: int
|
|
53
|
-
TimerPeriods: List[TimerPeriod] = Field(default_factory=list)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
class UserContext(BaseModel):
|
|
57
|
-
Id: str
|
|
58
|
-
EmailAddress: Optional[str] = None
|
|
59
|
-
Name: Optional[str] = None
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
class ApplianceStatus(BaseModel):
|
|
63
|
-
"""Represents the real-time status of an appliance as returned by GetApplianceOverview."""
|
|
64
|
-
|
|
65
|
-
HubId: str
|
|
66
|
-
ApplianceId: str
|
|
67
|
-
ZoneId: str
|
|
68
|
-
StatusTwo: Optional[int] = None
|
|
69
|
-
ApplianceModes: Optional[int] = None
|
|
70
|
-
RoomTemperature: Optional[float] = None
|
|
71
|
-
ActiveSetPointTemperature: Optional[int] = None
|
|
72
|
-
NormalTemperature: Optional[float] = None
|
|
73
|
-
AwayDateTime: Optional[str] = None
|
|
74
|
-
AwayTemperature: Optional[float] = None
|
|
75
|
-
BoostDuration: Optional[int] = None
|
|
76
|
-
BoostTemperature: Optional[float] = None
|
|
77
|
-
OpenWindowEnabled: Optional[bool] = None
|
|
78
|
-
EcoStartEnabled: Optional[bool] = None
|
|
79
|
-
SetbackEnabled: Optional[bool] = None
|
|
80
|
-
SetbackEnabledInStatusFrame: Optional[bool] = None
|
|
81
|
-
SetbackTemperature: Optional[float] = None
|
|
82
|
-
ComfortStatus: Optional[bool] = None
|
|
83
|
-
AvailableHotWater: Optional[float] = None
|
|
84
|
-
LockStatus: Optional[int] = None
|
|
85
|
-
ErrorCode: Optional[str] = None
|
|
86
|
-
WarningCode: Optional[str] = None
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
class ApplianceModeSettings(BaseModel):
|
|
90
|
-
"""Settings used to control appliance modes like Boost or Away."""
|
|
91
|
-
|
|
92
|
-
ApplianceModes: int
|
|
93
|
-
Status: int
|
|
94
|
-
Temperature: float = 23.0
|
|
95
|
-
Time: int = 0
|
|
96
|
-
Date: str = "0001-01-01T00:00:00"
|
|
97
|
-
StatusTwo: int = 0
|
|
98
|
-
NumberOfDays: int = 0
|
|
99
|
-
Frequency: int = 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|