dimplex-controller 1.0.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.
@@ -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.
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: dimplex-controller
3
+ Version: 1.0.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: Keiran 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
+ A Python asyncio client for controlling Dimplex heating systems (GDHV IoT).
33
+
34
+ ## Features
35
+
36
+ - **Authentication**: Easy login flow and automatic token refresh (Azure B2C).
37
+ - **Discovery**: List Hubs, Zones, and Appliances associated with your account.
38
+ - **Detailed Status**: Fetch real-time data including room temperature, setpoints, comfort status, and active boost settings.
39
+ - **Control**:
40
+ - Set operation modes (Manual, Timer, Frost Protection).
41
+ - Activate **Boost** and **Away** modes.
42
+ - Toggle **EcoStart** and **Open Window Detection**.
43
+ - Program heating schedules (Timer Periods).
44
+
45
+ ## Installation
46
+
47
+ This project is managed with Poetry.
48
+
49
+ ```bash
50
+ git clone <repo-url>
51
+ cd dimplex-controller-py
52
+ poetry install
53
+ ```
54
+
55
+ ## Getting Started
56
+
57
+ ### 1. Initial Authentication
58
+ Due to the nature of the Azure B2C flow, you must perform the initial login manually to capture an authorization code.
59
+
60
+ Run the demo script to guide you through the process:
61
+
62
+ ```bash
63
+ poetry run python demo.py
64
+ ```
65
+
66
+ Follow the on-screen instructions. Once successful, a `dimplex_tokens.json` file will be created, allowing the library to authenticate automatically in the future.
67
+
68
+ ### 2. Basic Usage
69
+
70
+ ```python
71
+ import asyncio
72
+ import aiohttp
73
+ from dimplex_controller import DimplexControl
74
+
75
+ async def main():
76
+ async with aiohttp.ClientSession() as session:
77
+ # Pass tokens from dimplex_tokens.json or just the refresh_token
78
+ client = DimplexControl(session, refresh_token="YOUR_REFRESH_TOKEN")
79
+
80
+ # Get Hubs
81
+ hubs = await client.get_hubs()
82
+ for hub in hubs:
83
+ print(f"Hub: {hub.Name}")
84
+
85
+ # Get Zones and Appliances
86
+ zones = await client.get_hub_zones(hub.HubId)
87
+ for zone in zones:
88
+ print(f" Zone: {zone.ZoneName}")
89
+
90
+ if __name__ == "__main__":
91
+ asyncio.run(main())
92
+ ```
93
+
94
+ ### 3. Advanced Operations
95
+
96
+ #### Get Real-time Status
97
+ ```python
98
+ # Fetch status for a list of appliance IDs
99
+ status_list = await client.get_appliance_overview(hub_id, ["appliance_id_1", "appliance_id_2"])
100
+
101
+ for status in status_list:
102
+ print(f"Temp: {status.RoomTemperature}°C, Target: {status.ActiveSetPointTemperature}°C")
103
+ print(f"EcoStart: {status.EcoStartEnabled}")
104
+ ```
105
+
106
+ #### Control Features
107
+ ```python
108
+ from dimplex_controller.models import ApplianceModeSettings
109
+
110
+ # Enable EcoStart
111
+ await client.set_eco_start(hub_id, [appliance_id], True)
112
+
113
+ # Enable Open Window Detection
114
+ await client.set_open_window_detection(hub_id, [appliance_id], True)
115
+
116
+ # Activate Boost (Mode 16, Status 1 = On)
117
+ boost_settings = ApplianceModeSettings(ApplianceModes=16, Status=1, Temperature=25.0)
118
+ await client.set_appliance_mode(hub_id, [appliance_id], boost_settings)
119
+ ```
120
+
121
+ ## Development & API Reference
122
+
123
+ - **`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.
124
+ - **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.
125
+
126
+ ## Disclaimer
127
+
128
+ This is an unofficial library and is not affiliated with or endorsed by Glen Dimplex Heating & Ventilation (GDHV). Use it at your own risk.
129
+
@@ -0,0 +1,99 @@
1
+ # Dimplex Controller Python Client
2
+
3
+ A Python asyncio client for controlling Dimplex heating systems (GDHV IoT).
4
+
5
+ ## Features
6
+
7
+ - **Authentication**: Easy login flow and automatic token refresh (Azure B2C).
8
+ - **Discovery**: List Hubs, Zones, and Appliances associated with your account.
9
+ - **Detailed Status**: Fetch real-time data including room temperature, setpoints, comfort status, and active boost settings.
10
+ - **Control**:
11
+ - Set operation modes (Manual, Timer, Frost Protection).
12
+ - Activate **Boost** and **Away** modes.
13
+ - Toggle **EcoStart** and **Open Window Detection**.
14
+ - Program heating schedules (Timer Periods).
15
+
16
+ ## Installation
17
+
18
+ This project is managed with Poetry.
19
+
20
+ ```bash
21
+ git clone <repo-url>
22
+ cd dimplex-controller-py
23
+ poetry install
24
+ ```
25
+
26
+ ## Getting Started
27
+
28
+ ### 1. Initial Authentication
29
+ Due to the nature of the Azure B2C flow, you must perform the initial login manually to capture an authorization code.
30
+
31
+ Run the demo script to guide you through the process:
32
+
33
+ ```bash
34
+ poetry run python demo.py
35
+ ```
36
+
37
+ Follow the on-screen instructions. Once successful, a `dimplex_tokens.json` file will be created, allowing the library to authenticate automatically in the future.
38
+
39
+ ### 2. Basic Usage
40
+
41
+ ```python
42
+ import asyncio
43
+ import aiohttp
44
+ from dimplex_controller import DimplexControl
45
+
46
+ async def main():
47
+ async with aiohttp.ClientSession() as session:
48
+ # Pass tokens from dimplex_tokens.json or just the refresh_token
49
+ client = DimplexControl(session, refresh_token="YOUR_REFRESH_TOKEN")
50
+
51
+ # Get Hubs
52
+ hubs = await client.get_hubs()
53
+ for hub in hubs:
54
+ print(f"Hub: {hub.Name}")
55
+
56
+ # Get Zones and Appliances
57
+ zones = await client.get_hub_zones(hub.HubId)
58
+ for zone in zones:
59
+ print(f" Zone: {zone.ZoneName}")
60
+
61
+ if __name__ == "__main__":
62
+ asyncio.run(main())
63
+ ```
64
+
65
+ ### 3. Advanced Operations
66
+
67
+ #### Get Real-time Status
68
+ ```python
69
+ # Fetch status for a list of appliance IDs
70
+ status_list = await client.get_appliance_overview(hub_id, ["appliance_id_1", "appliance_id_2"])
71
+
72
+ for status in status_list:
73
+ print(f"Temp: {status.RoomTemperature}°C, Target: {status.ActiveSetPointTemperature}°C")
74
+ print(f"EcoStart: {status.EcoStartEnabled}")
75
+ ```
76
+
77
+ #### Control Features
78
+ ```python
79
+ from dimplex_controller.models import ApplianceModeSettings
80
+
81
+ # Enable EcoStart
82
+ await client.set_eco_start(hub_id, [appliance_id], True)
83
+
84
+ # Enable Open Window Detection
85
+ await client.set_open_window_detection(hub_id, [appliance_id], True)
86
+
87
+ # Activate Boost (Mode 16, Status 1 = On)
88
+ boost_settings = ApplianceModeSettings(ApplianceModes=16, Status=1, Temperature=25.0)
89
+ await client.set_appliance_mode(hub_id, [appliance_id], boost_settings)
90
+ ```
91
+
92
+ ## Development & API Reference
93
+
94
+ - **`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.
95
+ - **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.
96
+
97
+ ## Disclaimer
98
+
99
+ This is an unofficial library and is not affiliated with or endorsed by Glen Dimplex Heating & Ventilation (GDHV). Use it at your own risk.
@@ -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,303 @@
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
+ _LOGGER.info(
109
+ f"Payload (partial code): client_id={payload['client_id']}, redirect_uri={payload['redirect_uri']}, code={code[:10]}..."
110
+ )
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
+ async def headless_login(self, email, password) -> None:
123
+ """Perform a headless login to obtain tokens."""
124
+ # 1. Get the login page
125
+ params = {
126
+ "client_id": CLIENT_ID,
127
+ "response_type": "code",
128
+ "redirect_uri": REDIRECT_URI,
129
+ "scope": SCOPE,
130
+ "response_mode": "query",
131
+ }
132
+ start_url = f"{AUTH_URL}/authorize?{urlencode(params)}"
133
+ _LOGGER.debug(f"Fetching login page: {start_url}")
134
+
135
+ async with self._session.get(start_url) as resp:
136
+ html = await resp.text()
137
+ final_url = str(resp.url)
138
+
139
+ # 2. Extract SETTINGS and CSRF
140
+ match = re.search(r"SETTINGS\s*=\s*({.*?});", html, re.DOTALL | re.MULTILINE)
141
+ if not match:
142
+ raise DimplexAuthError("Could not find SETTINGS in login page")
143
+
144
+ try:
145
+ settings = json.loads(match.group(1))
146
+ except json.JSONDecodeError:
147
+ raise DimplexAuthError("Failed to parse SETTINGS JSON from login page")
148
+
149
+ csrf_token = settings.get("csrf")
150
+ trans_id = settings.get("transId")
151
+
152
+ if not csrf_token or not trans_id:
153
+ raise DimplexAuthError("Missing csrf or transId in login page SETTINGS")
154
+
155
+ # 3. Construct SelfAsserted URL
156
+ if "/oauth2/v2.0/authorize" in final_url:
157
+ base_url = final_url.split("/oauth2/v2.0/authorize")[0]
158
+ else:
159
+ base_url = "https://gdhvb2c.b2clogin.com/gdhvb2c.onmicrosoft.com/B2C_1A_DimplexControlSignupSignin"
160
+
161
+ post_url = f"{base_url}/SelfAsserted"
162
+
163
+ params = {
164
+ "tx": trans_id,
165
+ "p": "B2C_1A_DimplexControlSignupSignin",
166
+ }
167
+
168
+ post_url_with_params = f"{post_url}?{urlencode(params)}"
169
+
170
+ # 4. Submit Credentials
171
+ # Extract hidden fields from the form to ensure we aren't missing anything
172
+ soup = BeautifulSoup(html, "html.parser")
173
+ form = soup.find("form", {"id": "localAccountForm"}) or soup.find("form")
174
+
175
+ form_data = {}
176
+ if form:
177
+ for input_tag in form.find_all("input"):
178
+ name = input_tag.get("name")
179
+ value = input_tag.get("value", "")
180
+ if name:
181
+ form_data[name] = value
182
+
183
+ # Overwrite with credentials
184
+ form_data.update(
185
+ {
186
+ "request_type": "RESPONSE",
187
+ "email": email,
188
+ "password": password,
189
+ }
190
+ )
191
+
192
+ headers = {
193
+ "X-CSRF-TOKEN": csrf_token,
194
+ "X-Requested-With": "XMLHttpRequest",
195
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
196
+ "Origin": "https://gdhvb2c.b2clogin.com",
197
+ "Referer": final_url,
198
+ }
199
+
200
+ _LOGGER.debug(f"Submitting credentials to {post_url_with_params}")
201
+ # _LOGGER.debug(f"Form data: {form_data}") # Security risk to log password
202
+
203
+ async with self._session.post(
204
+ post_url_with_params, data=form_data, headers=headers
205
+ ) as resp:
206
+ resp_text = await resp.text()
207
+ _LOGGER.debug(f"Login response status: {resp.status}")
208
+
209
+ try:
210
+ resp_json = json.loads(resp_text)
211
+ _LOGGER.debug(f"Login response JSON: {resp_json}")
212
+ except json.JSONDecodeError:
213
+ _LOGGER.debug(f"Login response Text: {resp_text}")
214
+ raise DimplexAuthError(f"Login response was not valid JSON: {resp_text[:100]}")
215
+
216
+ if resp_json.get("status") != "200":
217
+ raise DimplexAuthError(
218
+ f"Login failed: {resp_json.get('status')} - {resp_json.get('message') or resp_json.get('reason', 'Unknown reason')}"
219
+ )
220
+
221
+ # 5. Follow the 'Confirmed' step to get the actual code
222
+ # After a successful SelfAsserted, we usually need to make a GET to the 'CombinedSigninAndSignup' or similar endpoint
223
+ # to finalize the flow and get the redirect to our app with the code.
224
+ # Or, sometimes the SelfAsserted response sets a cookie and we just need to hit the authorize endpoint again.
225
+
226
+ # Let's try hitting the original authorize URL again (or the one we were redirected to).
227
+ # Since cookies are in the session, it should now redirect us to the app with the code.
228
+
229
+ _LOGGER.debug("Credentials accepted. Fetching authorize URL again to get code.")
230
+
231
+ # We need to allow redirects to capture the final msauth:// url
232
+ # aiohttp generic session checks redirects.
233
+ # But since the scheme is custom (msal...), aiohttp might throw an error or stop.
234
+
235
+ try:
236
+ async with self._session.get(start_url, allow_redirects=True) as resp:
237
+ # If we are here, it means we didn't crash on custom scheme yet,
238
+ # or we are at a page that directs us.
239
+ # Check history
240
+ pass
241
+ except aiohttp.ClientError:
242
+ # This might happen if the redirect schema is not http/https
243
+ # We can inspect the error or just capture it from the history if possible
244
+ pass
245
+ except Exception:
246
+ # If it tries to redirect to msal..., it might fail if aiohttp doesn't support it.
247
+ # We can disable redirects and follow manually to catch it.
248
+ pass
249
+
250
+ # Manual redirect following to catch custom scheme
251
+ current_url = start_url
252
+ code = None
253
+
254
+ for _ in range(10): # Max redirects
255
+ async with self._session.get(current_url, allow_redirects=False) as resp:
256
+ if resp.status in (302, 303, 301):
257
+ location = resp.headers.get("Location")
258
+ if not location:
259
+ break
260
+
261
+ if location.startswith(REDIRECT_URI) or "code=" in location:
262
+ # Success!
263
+ _LOGGER.debug(f"Found redirect with code: {location}")
264
+ # Extract code
265
+ parsed = urlparse(location)
266
+ query = parse_qs(parsed.query)
267
+ code = query.get("code", [""])[0]
268
+ break
269
+
270
+ current_url = location
271
+ else:
272
+ break
273
+
274
+ if not code:
275
+ raise DimplexAuthError(
276
+ "Failed to obtain auth code after successful login. Redirect URI might have changed."
277
+ )
278
+
279
+ _LOGGER.debug(f"Got code: {code[:10]}...")
280
+ await self.exchange_code(code)
281
+
282
+ def save_tokens(self, file_path: str) -> None:
283
+ """Save current tokens to a JSON file."""
284
+ data = {
285
+ "access_token": self._access_token,
286
+ "refresh_token": self._refresh_token,
287
+ "expires_at": self._expires_at,
288
+ }
289
+ with open(file_path, "w") as f:
290
+ json.dump(data, f, indent=2)
291
+ _LOGGER.info("Tokens saved to %s", file_path)
292
+
293
+ @classmethod
294
+ def load_tokens(cls, file_path: str) -> Optional[Dict]:
295
+ """Load tokens from a JSON file."""
296
+ if not os.path.exists(file_path):
297
+ return None
298
+ try:
299
+ with open(file_path, "r") as f:
300
+ return json.load(f)
301
+ except Exception as e:
302
+ _LOGGER.error("Failed to load tokens from %s: %s", file_path, e)
303
+ return None
@@ -0,0 +1,186 @@
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(
104
+ "GET", "/Zones/GetZonesAndAppliancesForHubId", params={"HubId": hub_id}
105
+ )
106
+ return [Zone(**z) for z in data]
107
+
108
+ async def get_zone(self, hub_id: str, zone_id: str) -> Zone:
109
+ """Get details for a specific zone."""
110
+ payload = {"HubId": hub_id, "ZoneId": zone_id}
111
+ data = await self._request("POST", "/Zones/GetZone", json=payload)
112
+ return Zone(**data)
113
+
114
+ async def get_appliance_overview(
115
+ self, hub_id: str, appliance_ids: List[str]
116
+ ) -> List[ApplianceStatus]:
117
+ """Get status overview for specific appliances."""
118
+ payload = {"HubId": hub_id, "ApplianceIds": appliance_ids}
119
+ data = await self._request("POST", "/RemoteControl/GetApplianceOverview", json=payload)
120
+ return [ApplianceStatus(**item) for item in data]
121
+
122
+ async def get_user_context(self) -> UserContext:
123
+ """Get user profile/context."""
124
+ data = await self._request("GET", "/Identity/GetUserContext")
125
+ return UserContext(**data)
126
+
127
+ async def get_appliance_features(self, hub_id: str, appliance_id: str) -> TimerModeSettings:
128
+ """Get timer details (and mode) for an appliance."""
129
+ # In the logs, this endpoint returns current mode and timer profiles
130
+ payload = {
131
+ "HubId": hub_id,
132
+ "ApplianceId": appliance_id,
133
+ "TimerMode": 0, # Required field in request, value doesn't seem to matter for fetching?
134
+ }
135
+ data = await self._request(
136
+ "POST", "/RemoteControl/GetTimerModeDetailsForAppliance", json=payload
137
+ )
138
+ return TimerModeSettings(**data)
139
+
140
+ async def set_mode(self, hub_id: str, appliance_id: str, mode: int) -> None:
141
+ """Set the operation mode.
142
+
143
+ Modes (inferred):
144
+ 0: Manual? (User Timer?)
145
+ 1: Manual
146
+ 2: Frost Protection
147
+ 3: Off?
148
+ """
149
+ # We need to fetch current settings first to preserve other fields if API requires full object
150
+ current = await self.get_appliance_features(hub_id, appliance_id)
151
+ current.TimerMode = mode
152
+
153
+ payload = {"TimerModeSettings": current.dict()}
154
+
155
+ await self._request("POST", "/RemoteControl/SetTimerMode", json=payload)
156
+
157
+ async def set_target_temperature(self, hub_id: str, appliance_id: str, temp: float) -> None:
158
+ """Set target temperature.
159
+
160
+ WARNING: The logs show setting temperature involves updating the 'TimerPeriods' for the current mode.
161
+ This client might need to be smarter about which period to update (current active one).
162
+ For now, this is a placeholder/advanced TODO.
163
+ """
164
+ _LOGGER.warning(
165
+ "set_target_temperature not fully implemented - requires complex schedule manipulation"
166
+ )
167
+ pass
168
+
169
+ async def set_appliance_mode(
170
+ self, hub_id: str, appliance_ids: List[str], mode_settings: ApplianceModeSettings
171
+ ) -> None:
172
+ """Set appliance mode (Boost, Away, etc.)."""
173
+ payload = {"Settings": mode_settings.dict(), "HubId": hub_id, "ApplianceIds": appliance_ids}
174
+ await self._request("POST", "/RemoteControl/SetApplianceMode", json=payload)
175
+
176
+ async def set_eco_start(self, hub_id: str, appliance_ids: List[str], enable: bool) -> None:
177
+ """Enable/Disable EcoStart."""
178
+ payload = {"Enable": enable, "HubId": hub_id, "ApplianceIds": appliance_ids}
179
+ await self._request("POST", "/RemoteControl/SetEcoStart", json=payload)
180
+
181
+ async def set_open_window_detection(
182
+ self, hub_id: str, appliance_ids: List[str], enable: bool
183
+ ) -> None:
184
+ """Enable/Disable Open Window Detection."""
185
+ payload = {"Enable": enable, "HubId": hub_id, "ApplianceIds": appliance_ids}
186
+ 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,48 @@
1
+ [tool.poetry]
2
+ name = "dimplex-controller"
3
+ version = "1.0.0"
4
+ description = "Python client for Dimplex heating controllers (GDHV IoT)"
5
+ authors = ["Keiran Roper"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ homepage = "https://github.com/KRoperUK/dimplex-controller-py"
9
+ repository = "https://github.com/KRoperUK/dimplex-controller-py"
10
+ keywords = ["dimplex", "heating", "control", "iot", "asyncio"]
11
+ classifiers = [
12
+ "Development Status :: 5 - Production/Stable",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Natural Language :: English",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Home Automation",
22
+ ]
23
+ packages = [{ include = "dimplex_controller" }]
24
+
25
+ [tool.poetry.dependencies]
26
+ python = "^3.10"
27
+ aiohttp = "^3.9.0"
28
+ pydantic = "^2.0.0"
29
+ beautifulsoup4 = "^4.14.3"
30
+ python-dotenv = "^1.2.1"
31
+ [tool.poetry.group.dev.dependencies]
32
+ pytest = "^8.0.0"
33
+ pytest-asyncio = "^0.23.0"
34
+ aresponses = "^3.0.0"
35
+ ruff = "^0.3.0"
36
+ pre-commit = "^3.6.0"
37
+ twine = "^6.2.0"
38
+
39
+ [tool.ruff]
40
+ line-length = 120
41
+ target-version = "py310"
42
+
43
+ [tool.ruff.lint]
44
+ select = ["E", "F", "I", "W"]
45
+
46
+ [build-system]
47
+ requires = ["poetry-core"]
48
+ build-backend = "poetry.core.masonry.api"