dimplex-controller 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dimplex_controller/__init__.py +18 -0
- dimplex_controller/auth.py +304 -0
- dimplex_controller/client.py +176 -0
- dimplex_controller/const.py +21 -0
- dimplex_controller/exceptions.py +22 -0
- dimplex_controller/models.py +99 -0
- dimplex_controller-0.2.0.dist-info/METADATA +136 -0
- dimplex_controller-0.2.0.dist-info/RECORD +10 -0
- dimplex_controller-0.2.0.dist-info/WHEEL +4 -0
- dimplex_controller-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Dimplex Controller Client."""
|
|
2
|
+
|
|
3
|
+
from .client import DimplexControl
|
|
4
|
+
from .exceptions import DimplexApiError, DimplexAuthError, DimplexConnectionError, DimplexError
|
|
5
|
+
from .models import Appliance, ApplianceModeSettings, ApplianceStatus, Hub, Zone
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"DimplexControl",
|
|
9
|
+
"Hub",
|
|
10
|
+
"Zone",
|
|
11
|
+
"Appliance",
|
|
12
|
+
"ApplianceStatus",
|
|
13
|
+
"ApplianceModeSettings",
|
|
14
|
+
"DimplexError",
|
|
15
|
+
"DimplexApiError",
|
|
16
|
+
"DimplexAuthError",
|
|
17
|
+
"DimplexConnectionError",
|
|
18
|
+
]
|
|
@@ -0,0 +1,304 @@
|
|
|
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
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
import aiohttp
|
|
5
|
+
|
|
6
|
+
from .auth import AuthManager
|
|
7
|
+
from .const import (
|
|
8
|
+
BASE_URL,
|
|
9
|
+
HEADER_APP_NAME,
|
|
10
|
+
HEADER_APP_VERSION,
|
|
11
|
+
HEADER_DEVICE_MANUFACTURER,
|
|
12
|
+
HEADER_DEVICE_MODEL,
|
|
13
|
+
HEADER_DEVICE_OS,
|
|
14
|
+
HEADER_DEVICE_VERSION,
|
|
15
|
+
HEADER_USER_AGENT,
|
|
16
|
+
HTTP_OK,
|
|
17
|
+
)
|
|
18
|
+
from .exceptions import DimplexApiError, DimplexConnectionError
|
|
19
|
+
from .models import (
|
|
20
|
+
ApplianceModeSettings,
|
|
21
|
+
ApplianceStatus,
|
|
22
|
+
Hub,
|
|
23
|
+
TimerModeSettings,
|
|
24
|
+
UserContext,
|
|
25
|
+
Zone,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_LOGGER = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DimplexControl:
|
|
32
|
+
"""Main client for Dimplex Control API."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
session: aiohttp.ClientSession,
|
|
37
|
+
refresh_token: Optional[str] = None,
|
|
38
|
+
access_token: Optional[str] = None,
|
|
39
|
+
expires_at: float = 0,
|
|
40
|
+
):
|
|
41
|
+
"""Initialize the client."""
|
|
42
|
+
token_data = {}
|
|
43
|
+
if refresh_token:
|
|
44
|
+
token_data["refresh_token"] = refresh_token
|
|
45
|
+
if access_token:
|
|
46
|
+
token_data["access_token"] = access_token
|
|
47
|
+
if expires_at:
|
|
48
|
+
token_data["expires_at"] = expires_at
|
|
49
|
+
|
|
50
|
+
self._session = session
|
|
51
|
+
self.auth = AuthManager(session, token_data)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def is_authenticated(self) -> bool:
|
|
55
|
+
"""Check if authenticated."""
|
|
56
|
+
return self.auth.is_authenticated
|
|
57
|
+
|
|
58
|
+
async def _request(self, method: str, endpoint: str, **kwargs) -> Dict:
|
|
59
|
+
"""Make an authenticated request."""
|
|
60
|
+
token = await self.auth.get_access_token()
|
|
61
|
+
headers = kwargs.pop("headers", {})
|
|
62
|
+
headers.update(
|
|
63
|
+
{
|
|
64
|
+
"Authorization": f"Bearer {token}",
|
|
65
|
+
"app_name": HEADER_APP_NAME,
|
|
66
|
+
"app_version": HEADER_APP_VERSION,
|
|
67
|
+
"app_device_os": HEADER_DEVICE_OS,
|
|
68
|
+
"device_version": HEADER_DEVICE_VERSION,
|
|
69
|
+
"device_manufacturer": HEADER_DEVICE_MANUFACTURER,
|
|
70
|
+
"device_model": HEADER_DEVICE_MODEL,
|
|
71
|
+
"User-Agent": HEADER_USER_AGENT,
|
|
72
|
+
"api_version": "1.0",
|
|
73
|
+
"Accept": "*/*",
|
|
74
|
+
"Accept-Encoding": "gzip, deflate, br",
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
url = f"{BASE_URL}{endpoint}"
|
|
80
|
+
try:
|
|
81
|
+
async with self._session.request(method, url, headers=headers, **kwargs) as resp:
|
|
82
|
+
if resp.status != HTTP_OK:
|
|
83
|
+
text = await resp.text()
|
|
84
|
+
_LOGGER.error("API request failed: %s - %s", resp.status, text)
|
|
85
|
+
raise DimplexApiError(resp.status, text)
|
|
86
|
+
|
|
87
|
+
# API might return empty body for some calls
|
|
88
|
+
if resp.content_length == 0:
|
|
89
|
+
return {}
|
|
90
|
+
return await resp.json()
|
|
91
|
+
except aiohttp.ClientError as e:
|
|
92
|
+
_LOGGER.error("Connection error during API request: %s", e)
|
|
93
|
+
raise DimplexConnectionError(f"Connection error: {e}") from e
|
|
94
|
+
|
|
95
|
+
async def get_hubs(self) -> List[Hub]:
|
|
96
|
+
"""Get all hubs for the user."""
|
|
97
|
+
data = await self._request("GET", "/Hubs/GetUserHubs")
|
|
98
|
+
# Log analysis shows list of objects
|
|
99
|
+
return [Hub(**h) for h in data]
|
|
100
|
+
|
|
101
|
+
async def get_hub_zones(self, hub_id: str) -> List[Zone]:
|
|
102
|
+
"""Get zones and appliances for a hub."""
|
|
103
|
+
data = await self._request("GET", "/Zones/GetZonesAndAppliancesForHubId", params={"HubId": hub_id})
|
|
104
|
+
return [Zone(**z) for z in data]
|
|
105
|
+
|
|
106
|
+
async def get_zone(self, hub_id: str, zone_id: str) -> Zone:
|
|
107
|
+
"""Get details for a specific zone."""
|
|
108
|
+
payload = {"HubId": hub_id, "ZoneId": zone_id}
|
|
109
|
+
data = await self._request("POST", "/Zones/GetZone", json=payload)
|
|
110
|
+
return Zone(**data)
|
|
111
|
+
|
|
112
|
+
async def get_appliance_overview(self, hub_id: str, appliance_ids: List[str]) -> List[ApplianceStatus]:
|
|
113
|
+
"""Get status overview for specific appliances."""
|
|
114
|
+
payload = {"HubId": hub_id, "ApplianceIds": appliance_ids}
|
|
115
|
+
data = await self._request("POST", "/RemoteControl/GetApplianceOverview", json=payload)
|
|
116
|
+
return [ApplianceStatus(**item) for item in data]
|
|
117
|
+
|
|
118
|
+
async def get_user_context(self) -> UserContext:
|
|
119
|
+
"""Get user profile/context."""
|
|
120
|
+
data = await self._request("GET", "/Identity/GetUserContext")
|
|
121
|
+
return UserContext(**data)
|
|
122
|
+
|
|
123
|
+
async def get_appliance_features(self, hub_id: str, appliance_id: str) -> TimerModeSettings:
|
|
124
|
+
"""Get timer details (and mode) for an appliance."""
|
|
125
|
+
# In the logs, this endpoint returns current mode and timer profiles
|
|
126
|
+
payload = {
|
|
127
|
+
"HubId": hub_id,
|
|
128
|
+
"ApplianceId": appliance_id,
|
|
129
|
+
"TimerMode": 0, # Required field in request, value doesn't seem to matter for fetching?
|
|
130
|
+
}
|
|
131
|
+
data = await self._request("POST", "/RemoteControl/GetTimerModeDetailsForAppliance", json=payload)
|
|
132
|
+
return TimerModeSettings(**data)
|
|
133
|
+
|
|
134
|
+
async def set_mode(self, hub_id: str, appliance_id: str, mode: int) -> None:
|
|
135
|
+
"""Set the operation mode.
|
|
136
|
+
|
|
137
|
+
Modes (inferred):
|
|
138
|
+
0: Manual? (User Timer?)
|
|
139
|
+
1: Manual
|
|
140
|
+
2: Frost Protection
|
|
141
|
+
3: Off?
|
|
142
|
+
"""
|
|
143
|
+
# We need to fetch current settings first to preserve other fields if API requires full object
|
|
144
|
+
current = await self.get_appliance_features(hub_id, appliance_id)
|
|
145
|
+
current.TimerMode = mode
|
|
146
|
+
|
|
147
|
+
payload = {"TimerModeSettings": current.dict()}
|
|
148
|
+
|
|
149
|
+
await self._request("POST", "/RemoteControl/SetTimerMode", json=payload)
|
|
150
|
+
|
|
151
|
+
async def set_target_temperature(self, hub_id: str, appliance_id: str, temp: float) -> None:
|
|
152
|
+
"""Set target temperature.
|
|
153
|
+
|
|
154
|
+
WARNING: The logs show setting temperature involves updating the 'TimerPeriods' for the current mode.
|
|
155
|
+
This client might need to be smarter about which period to update (current active one).
|
|
156
|
+
For now, this is a placeholder/advanced TODO.
|
|
157
|
+
"""
|
|
158
|
+
_LOGGER.warning("set_target_temperature not fully implemented - requires complex schedule manipulation")
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
async def set_appliance_mode(
|
|
162
|
+
self, hub_id: str, appliance_ids: List[str], mode_settings: ApplianceModeSettings
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Set appliance mode (Boost, Away, etc.)."""
|
|
165
|
+
payload = {"Settings": mode_settings.dict(), "HubId": hub_id, "ApplianceIds": appliance_ids}
|
|
166
|
+
await self._request("POST", "/RemoteControl/SetApplianceMode", json=payload)
|
|
167
|
+
|
|
168
|
+
async def set_eco_start(self, hub_id: str, appliance_ids: List[str], enable: bool) -> None:
|
|
169
|
+
"""Enable/Disable EcoStart."""
|
|
170
|
+
payload = {"Enable": enable, "HubId": hub_id, "ApplianceIds": appliance_ids}
|
|
171
|
+
await self._request("POST", "/RemoteControl/SetEcoStart", json=payload)
|
|
172
|
+
|
|
173
|
+
async def set_open_window_detection(self, hub_id: str, appliance_ids: List[str], enable: bool) -> None:
|
|
174
|
+
"""Enable/Disable Open Window Detection."""
|
|
175
|
+
payload = {"Enable": enable, "HubId": hub_id, "ApplianceIds": appliance_ids}
|
|
176
|
+
await self._request("POST", "/RemoteControl/SetOpenWindowDetection", json=payload)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Constants for Dimplex Controller."""
|
|
2
|
+
|
|
3
|
+
HTTP_OK = 200
|
|
4
|
+
|
|
5
|
+
# API Endpoints
|
|
6
|
+
BASE_URL = "https://mobileapi.gdhv-iot.com/api"
|
|
7
|
+
AUTH_URL = "https://gdhvb2c.b2clogin.com/tfp/gdhvb2c.onmicrosoft.com/B2C_1A_DimplexControlSignupSignin/oauth2/v2.0"
|
|
8
|
+
|
|
9
|
+
# Headers
|
|
10
|
+
HEADER_USER_AGENT = "Dimplex Control/79810 CFNetwork/3860.300.31 Darwin/25.2.0"
|
|
11
|
+
HEADER_APP_NAME = "DimplexControl"
|
|
12
|
+
HEADER_APP_VERSION = "2.21.0"
|
|
13
|
+
HEADER_DEVICE_OS = "iOS"
|
|
14
|
+
HEADER_DEVICE_VERSION = "26.2.1"
|
|
15
|
+
HEADER_DEVICE_MANUFACTURER = "Apple"
|
|
16
|
+
HEADER_DEVICE_MODEL = "iPhone18,1"
|
|
17
|
+
|
|
18
|
+
# Auth
|
|
19
|
+
CLIENT_ID = "6c983ca3-506e-4933-8993-0e18e6a24bbd"
|
|
20
|
+
SCOPE = "https://gdhvb2c.onmicrosoft.com/Mobile/read offline_access openid profile"
|
|
21
|
+
REDIRECT_URI = "msal6c983ca3-506e-4933-8993-0e18e6a24bbd://auth/"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Exceptions for Dimplex Controller."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DimplexError(Exception):
|
|
5
|
+
"""Base exception for Dimplex Controller."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DimplexAuthError(DimplexError):
|
|
9
|
+
"""Exception for authentication errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DimplexApiError(DimplexError):
|
|
13
|
+
"""Exception for API errors."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, status: int, message: str):
|
|
16
|
+
self.status = status
|
|
17
|
+
self.message = message
|
|
18
|
+
super().__init__(f"API Error {status}: {message}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DimplexConnectionError(DimplexError):
|
|
22
|
+
"""Exception for connection errors."""
|
|
@@ -0,0 +1,99 @@
|
|
|
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
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dimplex-controller
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python client for Dimplex heating controllers (GDHV IoT)
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: dimplex,heating,control,iot,asyncio
|
|
8
|
+
Author: Kieran Roper
|
|
9
|
+
Requires-Python: >=3.10,<4.0
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Natural Language :: English
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Home Automation
|
|
22
|
+
Requires-Dist: aiohttp (>=3.9.0,<4.0.0)
|
|
23
|
+
Requires-Dist: beautifulsoup4 (>=4.14.3,<5.0.0)
|
|
24
|
+
Requires-Dist: pydantic (>=2.0.0,<3.0.0)
|
|
25
|
+
Requires-Dist: python-dotenv (>=1.2.1,<2.0.0)
|
|
26
|
+
Project-URL: Homepage, https://github.com/KRoperUK/dimplex-controller-py
|
|
27
|
+
Project-URL: Repository, https://github.com/KRoperUK/dimplex-controller-py
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# Dimplex Controller Python Client
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/dimplex-controller/)
|
|
33
|
+
[](https://github.com/KRoperUK/dimplex-controller-py/actions)
|
|
34
|
+
[](https://pypi.org/project/dimplex-controller/)
|
|
35
|
+
[](https://www.python.org/downloads/)
|
|
36
|
+
[](LICENSE)
|
|
37
|
+
[](https://github.com/KRoperUK/dimplex-controller-py)
|
|
38
|
+
|
|
39
|
+
A Python asyncio client for controlling Dimplex heating systems (GDHV IoT).
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- **Authentication**: Easy login flow and automatic token refresh (Azure B2C).
|
|
44
|
+
- **Discovery**: List Hubs, Zones, and Appliances associated with your account.
|
|
45
|
+
- **Detailed Status**: Fetch real-time data including room temperature, setpoints, comfort status, and active boost settings.
|
|
46
|
+
- **Control**:
|
|
47
|
+
- Set operation modes (Manual, Timer, Frost Protection).
|
|
48
|
+
- Activate **Boost** and **Away** modes.
|
|
49
|
+
- Toggle **EcoStart** and **Open Window Detection**.
|
|
50
|
+
- Program heating schedules (Timer Periods).
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
This project is managed with Poetry.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
git clone <repo-url>
|
|
58
|
+
cd dimplex-controller-py
|
|
59
|
+
poetry install
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Getting Started
|
|
63
|
+
|
|
64
|
+
### 1. Initial Authentication
|
|
65
|
+
Due to the nature of the Azure B2C flow, you must perform the initial login manually to capture an authorization code.
|
|
66
|
+
|
|
67
|
+
Run the demo script to guide you through the process:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
poetry run python demo.py
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Follow the on-screen instructions. Once successful, a `dimplex_tokens.json` file will be created, allowing the library to authenticate automatically in the future.
|
|
74
|
+
|
|
75
|
+
### 2. Basic Usage
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
import asyncio
|
|
79
|
+
import aiohttp
|
|
80
|
+
from dimplex_controller import DimplexControl
|
|
81
|
+
|
|
82
|
+
async def main():
|
|
83
|
+
async with aiohttp.ClientSession() as session:
|
|
84
|
+
# Pass tokens from dimplex_tokens.json or just the refresh_token
|
|
85
|
+
client = DimplexControl(session, refresh_token="YOUR_REFRESH_TOKEN")
|
|
86
|
+
|
|
87
|
+
# Get Hubs
|
|
88
|
+
hubs = await client.get_hubs()
|
|
89
|
+
for hub in hubs:
|
|
90
|
+
print(f"Hub: {hub.Name}")
|
|
91
|
+
|
|
92
|
+
# Get Zones and Appliances
|
|
93
|
+
zones = await client.get_hub_zones(hub.HubId)
|
|
94
|
+
for zone in zones:
|
|
95
|
+
print(f" Zone: {zone.ZoneName}")
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
asyncio.run(main())
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### 3. Advanced Operations
|
|
102
|
+
|
|
103
|
+
#### Get Real-time Status
|
|
104
|
+
```python
|
|
105
|
+
# Fetch status for a list of appliance IDs
|
|
106
|
+
status_list = await client.get_appliance_overview(hub_id, ["appliance_id_1", "appliance_id_2"])
|
|
107
|
+
|
|
108
|
+
for status in status_list:
|
|
109
|
+
print(f"Temp: {status.RoomTemperature}°C, Target: {status.ActiveSetPointTemperature}°C")
|
|
110
|
+
print(f"EcoStart: {status.EcoStartEnabled}")
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
#### Control Features
|
|
114
|
+
```python
|
|
115
|
+
from dimplex_controller.models import ApplianceModeSettings
|
|
116
|
+
|
|
117
|
+
# Enable EcoStart
|
|
118
|
+
await client.set_eco_start(hub_id, [appliance_id], True)
|
|
119
|
+
|
|
120
|
+
# Enable Open Window Detection
|
|
121
|
+
await client.set_open_window_detection(hub_id, [appliance_id], True)
|
|
122
|
+
|
|
123
|
+
# Activate Boost (Mode 16, Status 1 = On)
|
|
124
|
+
boost_settings = ApplianceModeSettings(ApplianceModes=16, Status=1, Temperature=25.0)
|
|
125
|
+
await client.set_appliance_mode(hub_id, [appliance_id], boost_settings)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Development & API Reference
|
|
129
|
+
|
|
130
|
+
- **`openapi.yaml`**: This file contains the most complete technical specification of the API discovered so far. It includes all known endpoints, request bodies, and response schemas.
|
|
131
|
+
- **Traffic Logs**: If you identify new features in the mobile app, capture the traffic and add the endpoints to `openapi.yaml` and the `DimplexControl` client.
|
|
132
|
+
|
|
133
|
+
## Disclaimer
|
|
134
|
+
|
|
135
|
+
This is an unofficial library and is not affiliated with or endorsed by Glen Dimplex Heating & Ventilation (GDHV). Use it at your own risk.
|
|
136
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
dimplex_controller/__init__.py,sha256=MutAGagdOWNnUa8gJ4CW83PEy9Iqi1lAkXvYF4rlaaQ,472
|
|
2
|
+
dimplex_controller/auth.py,sha256=XT4AieR71IBCQEvVmH07nMJDL9WL-FgRFs2vvft_k20,11877
|
|
3
|
+
dimplex_controller/client.py,sha256=iBDfDqG9u_S8OD-BKD3kh0xuaTAcD_o3tXMRW4nvOvU,7063
|
|
4
|
+
dimplex_controller/const.py,sha256=fwoF3v_yd8166iuZM10nI1CXRJMbTBWfOiLKrJAxQWQ,731
|
|
5
|
+
dimplex_controller/exceptions.py,sha256=o6f8_yEkjeRqFUDkNuUIOKTP6saModIVM-6zA-L53L0,545
|
|
6
|
+
dimplex_controller/models.py,sha256=cPEvsUOOHqz4jpIOZXgwwRUaLg3vlH_yDEXstR54NhQ,2702
|
|
7
|
+
dimplex_controller-0.2.0.dist-info/METADATA,sha256=eAC-KqkYOtA1cl3rUy6DS72ZMdhpVY_hfbgjft9_P7I,5161
|
|
8
|
+
dimplex_controller-0.2.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
9
|
+
dimplex_controller-0.2.0.dist-info/licenses/LICENSE,sha256=rzc8mPdmZWDkt3mDIeVdic88b3IdkEhbZyn6wJOXzog,1069
|
|
10
|
+
dimplex_controller-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Keiran Roper
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|