dimplex-controller 0.2.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,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
+ [![PyPI](https://img.shields.io/pypi/v/dimplex-controller.svg)](https://pypi.org/project/dimplex-controller/)
33
+ [![Tests](https://github.com/KRoperUK/dimplex-controller-py/actions/workflows/tests.yml/badge.svg)](https://github.com/KRoperUK/dimplex-controller-py/actions)
34
+ [![Downloads](https://img.shields.io/pypi/dm/dimplex-controller.svg)](https://pypi.org/project/dimplex-controller/)
35
+ [![Python 3.10+](https://img.shields.io/badge/Python-3.10%2B-blue)](https://www.python.org/downloads/)
36
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
37
+ [![GitHub](https://img.shields.io/badge/GitHub-Repository-black?logo=github)](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,106 @@
1
+ # Dimplex Controller Python Client
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/dimplex-controller.svg)](https://pypi.org/project/dimplex-controller/)
4
+ [![Tests](https://github.com/KRoperUK/dimplex-controller-py/actions/workflows/tests.yml/badge.svg)](https://github.com/KRoperUK/dimplex-controller-py/actions)
5
+ [![Downloads](https://img.shields.io/pypi/dm/dimplex-controller.svg)](https://pypi.org/project/dimplex-controller/)
6
+ [![Python 3.10+](https://img.shields.io/badge/Python-3.10%2B-blue)](https://www.python.org/downloads/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
+ [![GitHub](https://img.shields.io/badge/GitHub-Repository-black?logo=github)](https://github.com/KRoperUK/dimplex-controller-py)
9
+
10
+ A Python asyncio client for controlling Dimplex heating systems (GDHV IoT).
11
+
12
+ ## Features
13
+
14
+ - **Authentication**: Easy login flow and automatic token refresh (Azure B2C).
15
+ - **Discovery**: List Hubs, Zones, and Appliances associated with your account.
16
+ - **Detailed Status**: Fetch real-time data including room temperature, setpoints, comfort status, and active boost settings.
17
+ - **Control**:
18
+ - Set operation modes (Manual, Timer, Frost Protection).
19
+ - Activate **Boost** and **Away** modes.
20
+ - Toggle **EcoStart** and **Open Window Detection**.
21
+ - Program heating schedules (Timer Periods).
22
+
23
+ ## Installation
24
+
25
+ This project is managed with Poetry.
26
+
27
+ ```bash
28
+ git clone <repo-url>
29
+ cd dimplex-controller-py
30
+ poetry install
31
+ ```
32
+
33
+ ## Getting Started
34
+
35
+ ### 1. Initial Authentication
36
+ Due to the nature of the Azure B2C flow, you must perform the initial login manually to capture an authorization code.
37
+
38
+ Run the demo script to guide you through the process:
39
+
40
+ ```bash
41
+ poetry run python demo.py
42
+ ```
43
+
44
+ Follow the on-screen instructions. Once successful, a `dimplex_tokens.json` file will be created, allowing the library to authenticate automatically in the future.
45
+
46
+ ### 2. Basic Usage
47
+
48
+ ```python
49
+ import asyncio
50
+ import aiohttp
51
+ from dimplex_controller import DimplexControl
52
+
53
+ async def main():
54
+ async with aiohttp.ClientSession() as session:
55
+ # Pass tokens from dimplex_tokens.json or just the refresh_token
56
+ client = DimplexControl(session, refresh_token="YOUR_REFRESH_TOKEN")
57
+
58
+ # Get Hubs
59
+ hubs = await client.get_hubs()
60
+ for hub in hubs:
61
+ print(f"Hub: {hub.Name}")
62
+
63
+ # Get Zones and Appliances
64
+ zones = await client.get_hub_zones(hub.HubId)
65
+ for zone in zones:
66
+ print(f" Zone: {zone.ZoneName}")
67
+
68
+ if __name__ == "__main__":
69
+ asyncio.run(main())
70
+ ```
71
+
72
+ ### 3. Advanced Operations
73
+
74
+ #### Get Real-time Status
75
+ ```python
76
+ # Fetch status for a list of appliance IDs
77
+ status_list = await client.get_appliance_overview(hub_id, ["appliance_id_1", "appliance_id_2"])
78
+
79
+ for status in status_list:
80
+ print(f"Temp: {status.RoomTemperature}°C, Target: {status.ActiveSetPointTemperature}°C")
81
+ print(f"EcoStart: {status.EcoStartEnabled}")
82
+ ```
83
+
84
+ #### Control Features
85
+ ```python
86
+ from dimplex_controller.models import ApplianceModeSettings
87
+
88
+ # Enable EcoStart
89
+ await client.set_eco_start(hub_id, [appliance_id], True)
90
+
91
+ # Enable Open Window Detection
92
+ await client.set_open_window_detection(hub_id, [appliance_id], True)
93
+
94
+ # Activate Boost (Mode 16, Status 1 = On)
95
+ boost_settings = ApplianceModeSettings(ApplianceModes=16, Status=1, Temperature=25.0)
96
+ await client.set_appliance_mode(hub_id, [appliance_id], boost_settings)
97
+ ```
98
+
99
+ ## Development & API Reference
100
+
101
+ - **`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.
102
+ - **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.
103
+
104
+ ## Disclaimer
105
+
106
+ 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,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,48 @@
1
+ [tool.poetry]
2
+ name = "dimplex-controller"
3
+ version = "0.2.0"
4
+ description = "Python client for Dimplex heating controllers (GDHV IoT)"
5
+ authors = ["Kieran 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"