dimplex-controller 0.10.1__tar.gz → 0.12.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dimplex_controller-0.10.1 → dimplex_controller-0.12.0}/PKG-INFO +50 -19
- {dimplex_controller-0.10.1 → dimplex_controller-0.12.0}/README.md +49 -17
- {dimplex_controller-0.10.1 → dimplex_controller-0.12.0}/dimplex_controller/__init__.py +2 -1
- {dimplex_controller-0.10.1 → dimplex_controller-0.12.0}/dimplex_controller/auth.py +29 -6
- {dimplex_controller-0.10.1 → dimplex_controller-0.12.0}/dimplex_controller/client.py +60 -8
- {dimplex_controller-0.10.1 → dimplex_controller-0.12.0}/pyproject.toml +15 -2
- {dimplex_controller-0.10.1 → dimplex_controller-0.12.0}/LICENSE +0 -0
- {dimplex_controller-0.10.1 → dimplex_controller-0.12.0}/dimplex_controller/capabilities.py +0 -0
- {dimplex_controller-0.10.1 → dimplex_controller-0.12.0}/dimplex_controller/cli.py +0 -0
- {dimplex_controller-0.10.1 → dimplex_controller-0.12.0}/dimplex_controller/const.py +0 -0
- {dimplex_controller-0.10.1 → dimplex_controller-0.12.0}/dimplex_controller/exceptions.py +0 -0
- {dimplex_controller-0.10.1 → dimplex_controller-0.12.0}/dimplex_controller/models.py +0 -0
- {dimplex_controller-0.10.1 → dimplex_controller-0.12.0}/dimplex_controller/py.typed +0 -0
- {dimplex_controller-0.10.1 → dimplex_controller-0.12.0}/dimplex_controller/telemetry.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dimplex-controller
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: Python client for Dimplex heating controllers (GDHV IoT)
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -20,7 +20,6 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.14
|
|
21
21
|
Classifier: Topic :: Home Automation
|
|
22
22
|
Requires-Dist: aiohttp (>=3.9.0,<4.0.0)
|
|
23
|
-
Requires-Dist: beautifulsoup4 (>=4.14.3,<5.0.0)
|
|
24
23
|
Requires-Dist: pydantic (>=2.0.0,<3.0.0)
|
|
25
24
|
Project-URL: Homepage, https://github.com/KRoperUK/dimplex-controller-py
|
|
26
25
|
Project-URL: Repository, https://github.com/KRoperUK/dimplex-controller-py
|
|
@@ -31,8 +30,9 @@ Description-Content-Type: text/markdown
|
|
|
31
30
|
[](https://pypi.org/project/dimplex-controller/)
|
|
32
31
|
[](https://www.python.org/downloads/)
|
|
33
32
|
[](LICENSE)
|
|
34
|
-
[](https://github.com/KRoperUK/dimplex-controller-py/actions/workflows/test.yml)
|
|
35
34
|
[](https://pypi.org/project/dimplex-controller/)
|
|
35
|
+
[](https://buymeacoffee.com/kroperukc)
|
|
36
36
|
|
|
37
37
|
<p align="center">
|
|
38
38
|
<strong>Async Python client for controlling Glen Dimplex Heating & Ventilation (GDHV) appliances via the Dimplex cloud API.</strong>
|
|
@@ -127,17 +127,22 @@ if __name__ == "__main__":
|
|
|
127
127
|
|
|
128
128
|
### Authentication
|
|
129
129
|
|
|
130
|
-
Dimplex uses Azure AD B2C.
|
|
130
|
+
Dimplex uses Azure AD B2C. The library supports two methods:
|
|
131
131
|
|
|
132
|
-
|
|
132
|
+
#### Email / password (headless login) — recommended
|
|
133
133
|
|
|
134
|
-
```
|
|
135
|
-
|
|
134
|
+
```python
|
|
135
|
+
client = DimplexControl(session)
|
|
136
|
+
await client.auth.headless_login("you@example.com", "password")
|
|
136
137
|
```
|
|
137
138
|
|
|
138
|
-
|
|
139
|
+
This automates the full B2C flow via HTTP. On success, `client.is_authenticated` is `True` and tokens can be persisted with `client.export_tokens()`.
|
|
140
|
+
|
|
141
|
+
#### Manual auth code (browser)
|
|
142
|
+
|
|
143
|
+
Run `demo.py` to open a browser, sign in, and paste the redirect URL. The script saves tokens to `dimplex_tokens.json`. Subsequent runs load the refresh token automatically.
|
|
139
144
|
|
|
140
|
-
|
|
145
|
+
Either way, refresh tokens are used on future calls — the library handles token renewal transparently.
|
|
141
146
|
|
|
142
147
|
### Discovery
|
|
143
148
|
|
|
@@ -216,22 +221,31 @@ See [docs/compatibility.md](docs/compatibility.md) for the library ↔ Home Assi
|
|
|
216
221
|
|
|
217
222
|
### `DimplexControl`
|
|
218
223
|
|
|
219
|
-
Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_token
|
|
224
|
+
Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_token` (or `token_bundle`).
|
|
220
225
|
|
|
221
226
|
| Method | Description |
|
|
222
227
|
|--------|-------------|
|
|
223
228
|
| `get_hubs()` | Returns `list[Hub]`. |
|
|
224
229
|
| `get_hub_zones(hub_id)` | Returns `list[Zone]` for a Hub. |
|
|
225
230
|
| `get_zone(hub_id, zone_id)` | Returns a single `Zone`. |
|
|
226
|
-
| `get_appliance_overview(hub_id, appliance_ids)` | Returns `list[ApplianceStatus]
|
|
231
|
+
| `get_appliance_overview(hub_id, appliance_ids)` | Returns `list[ApplianceStatus]` (may be `[]`). |
|
|
232
|
+
| `get_appliance_overview_map(hub_id, appliance_ids)` | Stable `dict[str, ApplianceStatus \| None]`. |
|
|
227
233
|
| `get_user_context()` | Returns `UserContext`. |
|
|
228
|
-
| `
|
|
229
|
-
| `
|
|
230
|
-
| `
|
|
231
|
-
| `
|
|
232
|
-
| `
|
|
233
|
-
| `
|
|
234
|
-
| `
|
|
234
|
+
| `get_product_models()` | Returns `list[ProductModel]` (cacheable). |
|
|
235
|
+
| `get_schedule(hub_id, appliance_id)` | Returns `TimerModeSettings` (timer + periods). |
|
|
236
|
+
| `set_mode(hub_id, appliance_id, mode)` | Change timer/operation mode. |
|
|
237
|
+
| `set_target_temperature(hub_id, appliance_id, temp)` | Rewrite all period setpoints or install full-week schedule. |
|
|
238
|
+
| `set_period_setpoint(...)` | Update one timer period without clobbering siblings. |
|
|
239
|
+
| `update_period(...)` | Replace a timer period matched by day + start time. |
|
|
240
|
+
| `set_boost(hub_id, appliance_ids, *, temperature, duration_minutes, enable)` | Enable/disable Boost. |
|
|
241
|
+
| `clear_boost(hub_id, appliance_ids)` | Disable Boost. |
|
|
242
|
+
| `set_away(hub_id, appliance_ids, *, temperature, enable, number_of_days)` | Enable/disable Away. |
|
|
243
|
+
| `clear_away(hub_id, appliance_ids)` | Disable Away. |
|
|
244
|
+
| `set_eco_start(hub_id, appliance_ids, enable)` | Toggle EcoStart. |
|
|
245
|
+
| `set_open_window_detection(hub_id, appliance_ids, enable)` | Toggle Open Window Detection. |
|
|
246
|
+
| `get_tsi_energy_report(hub_id, ...)` | Returns `TsiEnergyReport`. |
|
|
247
|
+
| `capabilities_for(appliance, *, status, product)` | Derive an `ApplianceCapabilities` matrix. |
|
|
248
|
+
| `export_tokens()` / `apply_tokens(bundle)` | Token persistence helpers. |
|
|
235
249
|
|
|
236
250
|
### Models
|
|
237
251
|
|
|
@@ -281,7 +295,24 @@ If `parse_telemetry_points` returns an empty list, the API likely returned an un
|
|
|
281
295
|
|
|
282
296
|
### Rate limiting
|
|
283
297
|
|
|
284
|
-
The GDHV cloud API has rate limits.
|
|
298
|
+
The GDHV cloud API has rate limits. The library retries idempotent `GET`
|
|
299
|
+
requests automatically on HTTP 429/5xx and connection errors, using exponential
|
|
300
|
+
backoff with jitter and honouring the `Retry-After` header when present.
|
|
301
|
+
Non-idempotent control calls (`POST`/`PUT`/`PATCH`/`DELETE`) are **not** retried
|
|
302
|
+
by default. Tune this via the client constructor:
|
|
303
|
+
|
|
304
|
+
```python
|
|
305
|
+
client = DimplexControl(
|
|
306
|
+
session,
|
|
307
|
+
refresh_token="...",
|
|
308
|
+
max_retries=3, # retries after the first attempt (0 disables)
|
|
309
|
+
retry_base_delay=0.5, # seconds; exponential base
|
|
310
|
+
retry_max_delay=8.0, # seconds; backoff ceiling
|
|
311
|
+
retry_non_idempotent=False, # set True to also retry POST/PUT/etc.
|
|
312
|
+
)
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
If you still hit persistent limits, back off for a few minutes before retrying.
|
|
285
316
|
|
|
286
317
|
### `get_appliance_overview` returns an empty list
|
|
287
318
|
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
[](https://pypi.org/project/dimplex-controller/)
|
|
4
4
|
[](https://www.python.org/downloads/)
|
|
5
5
|
[](LICENSE)
|
|
6
|
-
[](https://github.com/KRoperUK/dimplex-controller-py/actions/workflows/test.yml)
|
|
7
7
|
[](https://pypi.org/project/dimplex-controller/)
|
|
8
|
+
[](https://buymeacoffee.com/kroperukc)
|
|
8
9
|
|
|
9
10
|
<p align="center">
|
|
10
11
|
<strong>Async Python client for controlling Glen Dimplex Heating & Ventilation (GDHV) appliances via the Dimplex cloud API.</strong>
|
|
@@ -99,17 +100,22 @@ if __name__ == "__main__":
|
|
|
99
100
|
|
|
100
101
|
### Authentication
|
|
101
102
|
|
|
102
|
-
Dimplex uses Azure AD B2C.
|
|
103
|
+
Dimplex uses Azure AD B2C. The library supports two methods:
|
|
103
104
|
|
|
104
|
-
|
|
105
|
+
#### Email / password (headless login) — recommended
|
|
105
106
|
|
|
106
|
-
```
|
|
107
|
-
|
|
107
|
+
```python
|
|
108
|
+
client = DimplexControl(session)
|
|
109
|
+
await client.auth.headless_login("you@example.com", "password")
|
|
108
110
|
```
|
|
109
111
|
|
|
110
|
-
|
|
112
|
+
This automates the full B2C flow via HTTP. On success, `client.is_authenticated` is `True` and tokens can be persisted with `client.export_tokens()`.
|
|
113
|
+
|
|
114
|
+
#### Manual auth code (browser)
|
|
115
|
+
|
|
116
|
+
Run `demo.py` to open a browser, sign in, and paste the redirect URL. The script saves tokens to `dimplex_tokens.json`. Subsequent runs load the refresh token automatically.
|
|
111
117
|
|
|
112
|
-
|
|
118
|
+
Either way, refresh tokens are used on future calls — the library handles token renewal transparently.
|
|
113
119
|
|
|
114
120
|
### Discovery
|
|
115
121
|
|
|
@@ -188,22 +194,31 @@ See [docs/compatibility.md](docs/compatibility.md) for the library ↔ Home Assi
|
|
|
188
194
|
|
|
189
195
|
### `DimplexControl`
|
|
190
196
|
|
|
191
|
-
Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_token
|
|
197
|
+
Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_token` (or `token_bundle`).
|
|
192
198
|
|
|
193
199
|
| Method | Description |
|
|
194
200
|
|--------|-------------|
|
|
195
201
|
| `get_hubs()` | Returns `list[Hub]`. |
|
|
196
202
|
| `get_hub_zones(hub_id)` | Returns `list[Zone]` for a Hub. |
|
|
197
203
|
| `get_zone(hub_id, zone_id)` | Returns a single `Zone`. |
|
|
198
|
-
| `get_appliance_overview(hub_id, appliance_ids)` | Returns `list[ApplianceStatus]
|
|
204
|
+
| `get_appliance_overview(hub_id, appliance_ids)` | Returns `list[ApplianceStatus]` (may be `[]`). |
|
|
205
|
+
| `get_appliance_overview_map(hub_id, appliance_ids)` | Stable `dict[str, ApplianceStatus \| None]`. |
|
|
199
206
|
| `get_user_context()` | Returns `UserContext`. |
|
|
200
|
-
| `
|
|
201
|
-
| `
|
|
202
|
-
| `
|
|
203
|
-
| `
|
|
204
|
-
| `
|
|
205
|
-
| `
|
|
206
|
-
| `
|
|
207
|
+
| `get_product_models()` | Returns `list[ProductModel]` (cacheable). |
|
|
208
|
+
| `get_schedule(hub_id, appliance_id)` | Returns `TimerModeSettings` (timer + periods). |
|
|
209
|
+
| `set_mode(hub_id, appliance_id, mode)` | Change timer/operation mode. |
|
|
210
|
+
| `set_target_temperature(hub_id, appliance_id, temp)` | Rewrite all period setpoints or install full-week schedule. |
|
|
211
|
+
| `set_period_setpoint(...)` | Update one timer period without clobbering siblings. |
|
|
212
|
+
| `update_period(...)` | Replace a timer period matched by day + start time. |
|
|
213
|
+
| `set_boost(hub_id, appliance_ids, *, temperature, duration_minutes, enable)` | Enable/disable Boost. |
|
|
214
|
+
| `clear_boost(hub_id, appliance_ids)` | Disable Boost. |
|
|
215
|
+
| `set_away(hub_id, appliance_ids, *, temperature, enable, number_of_days)` | Enable/disable Away. |
|
|
216
|
+
| `clear_away(hub_id, appliance_ids)` | Disable Away. |
|
|
217
|
+
| `set_eco_start(hub_id, appliance_ids, enable)` | Toggle EcoStart. |
|
|
218
|
+
| `set_open_window_detection(hub_id, appliance_ids, enable)` | Toggle Open Window Detection. |
|
|
219
|
+
| `get_tsi_energy_report(hub_id, ...)` | Returns `TsiEnergyReport`. |
|
|
220
|
+
| `capabilities_for(appliance, *, status, product)` | Derive an `ApplianceCapabilities` matrix. |
|
|
221
|
+
| `export_tokens()` / `apply_tokens(bundle)` | Token persistence helpers. |
|
|
207
222
|
|
|
208
223
|
### Models
|
|
209
224
|
|
|
@@ -253,7 +268,24 @@ If `parse_telemetry_points` returns an empty list, the API likely returned an un
|
|
|
253
268
|
|
|
254
269
|
### Rate limiting
|
|
255
270
|
|
|
256
|
-
The GDHV cloud API has rate limits.
|
|
271
|
+
The GDHV cloud API has rate limits. The library retries idempotent `GET`
|
|
272
|
+
requests automatically on HTTP 429/5xx and connection errors, using exponential
|
|
273
|
+
backoff with jitter and honouring the `Retry-After` header when present.
|
|
274
|
+
Non-idempotent control calls (`POST`/`PUT`/`PATCH`/`DELETE`) are **not** retried
|
|
275
|
+
by default. Tune this via the client constructor:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
client = DimplexControl(
|
|
279
|
+
session,
|
|
280
|
+
refresh_token="...",
|
|
281
|
+
max_retries=3, # retries after the first attempt (0 disables)
|
|
282
|
+
retry_base_delay=0.5, # seconds; exponential base
|
|
283
|
+
retry_max_delay=8.0, # seconds; backoff ceiling
|
|
284
|
+
retry_non_idempotent=False, # set True to also retry POST/PUT/etc.
|
|
285
|
+
)
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
If you still hit persistent limits, back off for a few minutes before retrying.
|
|
257
289
|
|
|
258
290
|
### `get_appliance_overview` returns an empty list
|
|
259
291
|
|
|
@@ -11,7 +11,7 @@ successful poll, not an error — use ``get_appliance_overview_map`` if you
|
|
|
11
11
|
need a stable id → status mapping.
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
from .auth import TokenBundle
|
|
14
|
+
from .auth import TokenBundle, TokenListener
|
|
15
15
|
from .capabilities import ApplianceCapabilities, capabilities_for
|
|
16
16
|
from .client import DimplexControl
|
|
17
17
|
from .exceptions import (
|
|
@@ -50,6 +50,7 @@ from .telemetry import (
|
|
|
50
50
|
__all__ = [
|
|
51
51
|
"DimplexControl",
|
|
52
52
|
"TokenBundle",
|
|
53
|
+
"TokenListener",
|
|
53
54
|
"ApplianceCapabilities",
|
|
54
55
|
"capabilities_for",
|
|
55
56
|
"Hub",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import inspect
|
|
4
5
|
import json
|
|
5
6
|
import logging
|
|
@@ -39,6 +40,15 @@ TokenListener = Callable[["TokenBundle"], Awaitable[None] | None]
|
|
|
39
40
|
_MAX_REDIRECT_HOPS = 20
|
|
40
41
|
|
|
41
42
|
|
|
43
|
+
def _coerce_auth_timeout(timeout: aiohttp.ClientTimeout | float | None) -> aiohttp.ClientTimeout | None:
|
|
44
|
+
"""Normalise an auth timeout into an :class:`aiohttp.ClientTimeout`."""
|
|
45
|
+
if timeout is None:
|
|
46
|
+
return None
|
|
47
|
+
if isinstance(timeout, aiohttp.ClientTimeout):
|
|
48
|
+
return timeout
|
|
49
|
+
return aiohttp.ClientTimeout(total=float(timeout))
|
|
50
|
+
|
|
51
|
+
|
|
42
52
|
@dataclass(frozen=True, slots=True)
|
|
43
53
|
class TokenBundle:
|
|
44
54
|
"""Public, serialisable snapshot of auth tokens."""
|
|
@@ -76,15 +86,28 @@ class AuthManager:
|
|
|
76
86
|
token_data: dict[str, Any] | TokenBundle | None = None,
|
|
77
87
|
*,
|
|
78
88
|
on_token_update: TokenListener | None = None,
|
|
89
|
+
timeout: aiohttp.ClientTimeout | float | None = None,
|
|
79
90
|
):
|
|
80
|
-
"""Initialize the auth manager.
|
|
91
|
+
"""Initialize the auth manager.
|
|
92
|
+
|
|
93
|
+
``timeout`` bounds every auth request (token refresh, code exchange, and
|
|
94
|
+
the headless B2C login). Pass a number for a total timeout in seconds,
|
|
95
|
+
an :class:`aiohttp.ClientTimeout`, or ``None`` for aiohttp defaults.
|
|
96
|
+
"""
|
|
81
97
|
self._session = session
|
|
82
98
|
self._on_token_update = on_token_update
|
|
99
|
+
self._timeout = _coerce_auth_timeout(timeout)
|
|
83
100
|
bundle = token_data if isinstance(token_data, TokenBundle) else TokenBundle.from_mapping(token_data)
|
|
84
101
|
self._access_token: str | None = bundle.access_token
|
|
85
102
|
self._refresh_token: str | None = bundle.refresh_token
|
|
86
103
|
self._expires_at: float = bundle.expires_at
|
|
87
104
|
|
|
105
|
+
def _request_kwargs(self) -> dict[str, Any]:
|
|
106
|
+
"""Common per-request kwargs (applies the configured timeout)."""
|
|
107
|
+
if self._timeout is not None:
|
|
108
|
+
return {"timeout": self._timeout}
|
|
109
|
+
return {}
|
|
110
|
+
|
|
88
111
|
@property
|
|
89
112
|
def is_authenticated(self) -> bool:
|
|
90
113
|
"""Check if we have a valid access token."""
|
|
@@ -139,7 +162,7 @@ class AuthManager:
|
|
|
139
162
|
}
|
|
140
163
|
|
|
141
164
|
try:
|
|
142
|
-
async with self._session.post(f"{AUTH_URL}/token", data=payload) as resp:
|
|
165
|
+
async with self._session.post(f"{AUTH_URL}/token", data=payload, **self._request_kwargs()) as resp:
|
|
143
166
|
if resp.status != HTTP_OK:
|
|
144
167
|
text = await resp.text()
|
|
145
168
|
_LOGGER.error(
|
|
@@ -153,7 +176,7 @@ class AuthManager:
|
|
|
153
176
|
await self._update_tokens(data)
|
|
154
177
|
except DimplexAuthError:
|
|
155
178
|
raise
|
|
156
|
-
except aiohttp.ClientError as exc:
|
|
179
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
|
|
157
180
|
raise DimplexAuthTransientError(
|
|
158
181
|
f"Network error while refreshing token: {type(exc).__name__}",
|
|
159
182
|
details=str(exc)[:200],
|
|
@@ -217,7 +240,7 @@ class AuthManager:
|
|
|
217
240
|
|
|
218
241
|
_LOGGER.debug("Exchanging authorization code for tokens")
|
|
219
242
|
try:
|
|
220
|
-
async with self._session.post(f"{AUTH_URL}/token", data=payload) as resp:
|
|
243
|
+
async with self._session.post(f"{AUTH_URL}/token", data=payload, **self._request_kwargs()) as resp:
|
|
221
244
|
if resp.status != HTTP_OK:
|
|
222
245
|
text = await resp.text()
|
|
223
246
|
_LOGGER.error(
|
|
@@ -231,7 +254,7 @@ class AuthManager:
|
|
|
231
254
|
await self._update_tokens(data)
|
|
232
255
|
except DimplexAuthError:
|
|
233
256
|
raise
|
|
234
|
-
except aiohttp.ClientError as exc:
|
|
257
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
|
|
235
258
|
raise DimplexAuthTransientError(
|
|
236
259
|
f"Network error while exchanging code: {type(exc).__name__}",
|
|
237
260
|
details=str(exc)[:200],
|
|
@@ -291,7 +314,7 @@ class AuthManager:
|
|
|
291
314
|
start_url = self.get_login_url()
|
|
292
315
|
|
|
293
316
|
try:
|
|
294
|
-
async with aiohttp.ClientSession(cookie_jar=jar) as session:
|
|
317
|
+
async with aiohttp.ClientSession(cookie_jar=jar, timeout=self._timeout) as session:
|
|
295
318
|
# Step 1: GET the auth URI, follow redirects to B2C login page
|
|
296
319
|
_LOGGER.debug("Fetching B2C login page")
|
|
297
320
|
try:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import json
|
|
4
5
|
import logging
|
|
5
6
|
import random
|
|
6
7
|
from datetime import datetime, timedelta, timezone
|
|
@@ -8,7 +9,7 @@ from typing import Any
|
|
|
8
9
|
|
|
9
10
|
import aiohttp
|
|
10
11
|
|
|
11
|
-
from .auth import AuthManager, TokenBundle
|
|
12
|
+
from .auth import AuthManager, TokenBundle, TokenListener
|
|
12
13
|
from .capabilities import ApplianceCapabilities, capabilities_for
|
|
13
14
|
from .const import (
|
|
14
15
|
BASE_URL,
|
|
@@ -55,6 +56,24 @@ DEFAULT_RETRY_BASE_DELAY = 0.5
|
|
|
55
56
|
DEFAULT_RETRY_MAX_DELAY = 8.0
|
|
56
57
|
_RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
|
|
57
58
|
|
|
59
|
+
# Default total request timeout in seconds. Without this aiohttp falls back to a
|
|
60
|
+
# 5-minute default, which can hang a caller (e.g. a Home Assistant coordinator
|
|
61
|
+
# poll) on a stalled connection. Callers may override per-client.
|
|
62
|
+
DEFAULT_TIMEOUT = 30.0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _coerce_timeout(timeout: float | aiohttp.ClientTimeout | None) -> aiohttp.ClientTimeout | None:
|
|
66
|
+
"""Normalise a timeout value into an :class:`aiohttp.ClientTimeout`.
|
|
67
|
+
|
|
68
|
+
``None`` disables the client-level timeout (aiohttp defaults apply); a
|
|
69
|
+
number is treated as the total request timeout in seconds.
|
|
70
|
+
"""
|
|
71
|
+
if timeout is None:
|
|
72
|
+
return None
|
|
73
|
+
if isinstance(timeout, aiohttp.ClientTimeout):
|
|
74
|
+
return timeout
|
|
75
|
+
return aiohttp.ClientTimeout(total=float(timeout))
|
|
76
|
+
|
|
58
77
|
|
|
59
78
|
def _iso_utc_days_ago(days: int) -> str:
|
|
60
79
|
"""Return an ISO-8601 UTC timestamp ``days`` before now (no microseconds)."""
|
|
@@ -77,6 +96,8 @@ class DimplexControl:
|
|
|
77
96
|
retry_base_delay: float = DEFAULT_RETRY_BASE_DELAY,
|
|
78
97
|
retry_max_delay: float = DEFAULT_RETRY_MAX_DELAY,
|
|
79
98
|
retry_non_idempotent: bool = False,
|
|
99
|
+
timeout: float | aiohttp.ClientTimeout | None = DEFAULT_TIMEOUT,
|
|
100
|
+
on_token_update: TokenListener | None = None,
|
|
80
101
|
):
|
|
81
102
|
"""Initialize the client.
|
|
82
103
|
|
|
@@ -93,6 +114,17 @@ class DimplexControl:
|
|
|
93
114
|
policy (use with care).
|
|
94
115
|
* ``max_retries`` is the number of *retries* after the first attempt
|
|
95
116
|
(0 disables retries).
|
|
117
|
+
|
|
118
|
+
``timeout`` bounds every request (API and auth). Pass a number for a
|
|
119
|
+
total timeout in seconds (default 30s), an :class:`aiohttp.ClientTimeout`
|
|
120
|
+
for fine-grained control, or ``None`` to fall back to aiohttp defaults.
|
|
121
|
+
A timed-out request is surfaced as :class:`DimplexConnectionError` and,
|
|
122
|
+
for retryable methods, retried like any other connection error.
|
|
123
|
+
|
|
124
|
+
``on_token_update`` is an optional callback (sync or async) invoked with
|
|
125
|
+
a :class:`TokenBundle` whenever tokens are refreshed or exchanged. Use
|
|
126
|
+
it to persist tokens reactively (e.g. write to a config-entry store)
|
|
127
|
+
rather than polling :meth:`export_tokens` after every request.
|
|
96
128
|
"""
|
|
97
129
|
if token_bundle is not None:
|
|
98
130
|
token_data: dict[str, Any] | TokenBundle = token_bundle
|
|
@@ -106,7 +138,8 @@ class DimplexControl:
|
|
|
106
138
|
token_data["expires_at"] = expires_at
|
|
107
139
|
|
|
108
140
|
self._session = session
|
|
109
|
-
self.
|
|
141
|
+
self._timeout = _coerce_timeout(timeout)
|
|
142
|
+
self.auth = AuthManager(session, token_data, timeout=self._timeout, on_token_update=on_token_update)
|
|
110
143
|
self._max_retries = max(0, int(max_retries))
|
|
111
144
|
self._retry_base_delay = float(retry_base_delay)
|
|
112
145
|
self._retry_max_delay = float(retry_max_delay)
|
|
@@ -170,6 +203,8 @@ class DimplexControl:
|
|
|
170
203
|
)
|
|
171
204
|
|
|
172
205
|
url = f"{BASE_URL}{endpoint}"
|
|
206
|
+
if self._timeout is not None and "timeout" not in kwargs:
|
|
207
|
+
kwargs["timeout"] = self._timeout
|
|
173
208
|
allow_retry = self._should_retry(method)
|
|
174
209
|
attempts = self._max_retries + 1 if allow_retry else 1
|
|
175
210
|
last_error: Exception | None = None
|
|
@@ -178,9 +213,7 @@ class DimplexControl:
|
|
|
178
213
|
try:
|
|
179
214
|
async with self._session.request(method, url, headers=headers, **kwargs) as resp:
|
|
180
215
|
if resp.status == HTTP_OK:
|
|
181
|
-
|
|
182
|
-
return {}
|
|
183
|
-
return await resp.json()
|
|
216
|
+
return await self._decode_ok_body(resp)
|
|
184
217
|
|
|
185
218
|
text = await resp.text()
|
|
186
219
|
retry_after = self._parse_retry_after(resp.headers.get("Retry-After"))
|
|
@@ -192,7 +225,7 @@ class DimplexControl:
|
|
|
192
225
|
endpoint,
|
|
193
226
|
resp.status,
|
|
194
227
|
attempt + 1,
|
|
195
|
-
|
|
228
|
+
attempts - 1,
|
|
196
229
|
delay,
|
|
197
230
|
)
|
|
198
231
|
last_error = DimplexApiError(resp.status, text)
|
|
@@ -201,7 +234,7 @@ class DimplexControl:
|
|
|
201
234
|
|
|
202
235
|
_LOGGER.error("API request failed: %s - %s", resp.status, text)
|
|
203
236
|
raise DimplexApiError(resp.status, text)
|
|
204
|
-
except aiohttp.ClientError as e:
|
|
237
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
205
238
|
if allow_retry and attempt + 1 < attempts:
|
|
206
239
|
delay = self._backoff_seconds(attempt)
|
|
207
240
|
_LOGGER.warning(
|
|
@@ -209,7 +242,7 @@ class DimplexControl:
|
|
|
209
242
|
method,
|
|
210
243
|
endpoint,
|
|
211
244
|
attempt + 1,
|
|
212
|
-
|
|
245
|
+
attempts - 1,
|
|
213
246
|
delay,
|
|
214
247
|
e,
|
|
215
248
|
)
|
|
@@ -225,6 +258,25 @@ class DimplexControl:
|
|
|
225
258
|
raise last_error
|
|
226
259
|
raise DimplexConnectionError("Request failed after retries")
|
|
227
260
|
|
|
261
|
+
@staticmethod
|
|
262
|
+
async def _decode_ok_body(resp: aiohttp.ClientResponse) -> Any:
|
|
263
|
+
"""Decode a 2xx JSON body, tolerating empty responses.
|
|
264
|
+
|
|
265
|
+
Some Dimplex control endpoints reply ``200 OK`` with an empty body (no
|
|
266
|
+
``Content-Length`` when chunked/compressed), so ``resp.content_length``
|
|
267
|
+
alone is unreliable. Read the raw text and treat empty/whitespace as an
|
|
268
|
+
empty object. A non-empty body that fails to parse is wrapped as a
|
|
269
|
+
:class:`DimplexConnectionError` rather than escaping as a raw
|
|
270
|
+
``JSONDecodeError``.
|
|
271
|
+
"""
|
|
272
|
+
text = await resp.text()
|
|
273
|
+
if not text or not text.strip():
|
|
274
|
+
return {}
|
|
275
|
+
try:
|
|
276
|
+
return json.loads(text)
|
|
277
|
+
except json.JSONDecodeError as exc:
|
|
278
|
+
raise DimplexConnectionError(f"Invalid JSON in response: {exc}") from exc
|
|
279
|
+
|
|
228
280
|
@staticmethod
|
|
229
281
|
def capabilities_for(
|
|
230
282
|
appliance: Appliance | None = None,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "dimplex-controller"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.12.0"
|
|
4
4
|
description = "Python client for Dimplex heating controllers (GDHV IoT)"
|
|
5
5
|
authors = ["Kieran Roper"]
|
|
6
6
|
license = "MIT"
|
|
@@ -30,7 +30,6 @@ dimplex = "dimplex_controller.cli:main"
|
|
|
30
30
|
python = "^3.10"
|
|
31
31
|
aiohttp = "^3.9.0"
|
|
32
32
|
pydantic = "^2.0.0"
|
|
33
|
-
beautifulsoup4 = "^4.14.3"
|
|
34
33
|
|
|
35
34
|
[tool.poetry.group.dev.dependencies]
|
|
36
35
|
pytest = "^8.0.0"
|
|
@@ -61,6 +60,20 @@ python_version = "3.10"
|
|
|
61
60
|
files = ["dimplex_controller"]
|
|
62
61
|
ignore_missing_imports = true
|
|
63
62
|
|
|
63
|
+
[tool.pytest.ini_options]
|
|
64
|
+
# All async tests use asyncio; "auto" means new tests don't each need the
|
|
65
|
+
# @pytest.mark.asyncio decorator (existing markers remain valid).
|
|
66
|
+
asyncio_mode = "auto"
|
|
67
|
+
testpaths = ["tests"]
|
|
68
|
+
|
|
69
|
+
[tool.coverage.run]
|
|
70
|
+
source = ["dimplex_controller"]
|
|
71
|
+
|
|
72
|
+
[tool.coverage.report]
|
|
73
|
+
show_missing = true
|
|
74
|
+
# Guardrail so coverage can't silently regress. Raise as coverage improves.
|
|
75
|
+
fail_under = 80
|
|
76
|
+
|
|
64
77
|
[build-system]
|
|
65
78
|
requires = ["poetry-core"]
|
|
66
79
|
build-backend = "poetry.core.masonry.api"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|