dimplex-controller 0.10.1__tar.gz → 0.11.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.11.0}/PKG-INFO +48 -18
- {dimplex_controller-0.10.1 → dimplex_controller-0.11.0}/README.md +47 -16
- {dimplex_controller-0.10.1 → dimplex_controller-0.11.0}/dimplex_controller/auth.py +29 -6
- {dimplex_controller-0.10.1 → dimplex_controller-0.11.0}/dimplex_controller/client.py +53 -7
- {dimplex_controller-0.10.1 → dimplex_controller-0.11.0}/pyproject.toml +15 -2
- {dimplex_controller-0.10.1 → dimplex_controller-0.11.0}/LICENSE +0 -0
- {dimplex_controller-0.10.1 → dimplex_controller-0.11.0}/dimplex_controller/__init__.py +0 -0
- {dimplex_controller-0.10.1 → dimplex_controller-0.11.0}/dimplex_controller/capabilities.py +0 -0
- {dimplex_controller-0.10.1 → dimplex_controller-0.11.0}/dimplex_controller/cli.py +0 -0
- {dimplex_controller-0.10.1 → dimplex_controller-0.11.0}/dimplex_controller/const.py +0 -0
- {dimplex_controller-0.10.1 → dimplex_controller-0.11.0}/dimplex_controller/exceptions.py +0 -0
- {dimplex_controller-0.10.1 → dimplex_controller-0.11.0}/dimplex_controller/models.py +0 -0
- {dimplex_controller-0.10.1 → dimplex_controller-0.11.0}/dimplex_controller/py.typed +0 -0
- {dimplex_controller-0.10.1 → dimplex_controller-0.11.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.11.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
|
|
@@ -127,17 +126,22 @@ if __name__ == "__main__":
|
|
|
127
126
|
|
|
128
127
|
### Authentication
|
|
129
128
|
|
|
130
|
-
Dimplex uses Azure AD B2C.
|
|
129
|
+
Dimplex uses Azure AD B2C. The library supports two methods:
|
|
131
130
|
|
|
132
|
-
|
|
131
|
+
#### Email / password (headless login) — recommended
|
|
133
132
|
|
|
134
|
-
```
|
|
135
|
-
|
|
133
|
+
```python
|
|
134
|
+
client = DimplexControl(session)
|
|
135
|
+
await client.auth.headless_login("you@example.com", "password")
|
|
136
136
|
```
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
This automates the full B2C flow via HTTP. On success, `client.is_authenticated` is `True` and tokens can be persisted with `client.export_tokens()`.
|
|
139
|
+
|
|
140
|
+
#### Manual auth code (browser)
|
|
141
|
+
|
|
142
|
+
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
143
|
|
|
140
|
-
|
|
144
|
+
Either way, refresh tokens are used on future calls — the library handles token renewal transparently.
|
|
141
145
|
|
|
142
146
|
### Discovery
|
|
143
147
|
|
|
@@ -216,22 +220,31 @@ See [docs/compatibility.md](docs/compatibility.md) for the library ↔ Home Assi
|
|
|
216
220
|
|
|
217
221
|
### `DimplexControl`
|
|
218
222
|
|
|
219
|
-
Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_token
|
|
223
|
+
Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_token` (or `token_bundle`).
|
|
220
224
|
|
|
221
225
|
| Method | Description |
|
|
222
226
|
|--------|-------------|
|
|
223
227
|
| `get_hubs()` | Returns `list[Hub]`. |
|
|
224
228
|
| `get_hub_zones(hub_id)` | Returns `list[Zone]` for a Hub. |
|
|
225
229
|
| `get_zone(hub_id, zone_id)` | Returns a single `Zone`. |
|
|
226
|
-
| `get_appliance_overview(hub_id, appliance_ids)` | Returns `list[ApplianceStatus]
|
|
230
|
+
| `get_appliance_overview(hub_id, appliance_ids)` | Returns `list[ApplianceStatus]` (may be `[]`). |
|
|
231
|
+
| `get_appliance_overview_map(hub_id, appliance_ids)` | Stable `dict[str, ApplianceStatus \| None]`. |
|
|
227
232
|
| `get_user_context()` | Returns `UserContext`. |
|
|
228
|
-
| `
|
|
229
|
-
| `
|
|
230
|
-
| `
|
|
231
|
-
| `
|
|
232
|
-
| `
|
|
233
|
-
| `
|
|
234
|
-
| `
|
|
233
|
+
| `get_product_models()` | Returns `list[ProductModel]` (cacheable). |
|
|
234
|
+
| `get_schedule(hub_id, appliance_id)` | Returns `TimerModeSettings` (timer + periods). |
|
|
235
|
+
| `set_mode(hub_id, appliance_id, mode)` | Change timer/operation mode. |
|
|
236
|
+
| `set_target_temperature(hub_id, appliance_id, temp)` | Rewrite all period setpoints or install full-week schedule. |
|
|
237
|
+
| `set_period_setpoint(...)` | Update one timer period without clobbering siblings. |
|
|
238
|
+
| `update_period(...)` | Replace a timer period matched by day + start time. |
|
|
239
|
+
| `set_boost(hub_id, appliance_ids, *, temperature, duration_minutes, enable)` | Enable/disable Boost. |
|
|
240
|
+
| `clear_boost(hub_id, appliance_ids)` | Disable Boost. |
|
|
241
|
+
| `set_away(hub_id, appliance_ids, *, temperature, enable, number_of_days)` | Enable/disable Away. |
|
|
242
|
+
| `clear_away(hub_id, appliance_ids)` | Disable Away. |
|
|
243
|
+
| `set_eco_start(hub_id, appliance_ids, enable)` | Toggle EcoStart. |
|
|
244
|
+
| `set_open_window_detection(hub_id, appliance_ids, enable)` | Toggle Open Window Detection. |
|
|
245
|
+
| `get_tsi_energy_report(hub_id, ...)` | Returns `TsiEnergyReport`. |
|
|
246
|
+
| `capabilities_for(appliance, *, status, product)` | Derive an `ApplianceCapabilities` matrix. |
|
|
247
|
+
| `export_tokens()` / `apply_tokens(bundle)` | Token persistence helpers. |
|
|
235
248
|
|
|
236
249
|
### Models
|
|
237
250
|
|
|
@@ -281,7 +294,24 @@ If `parse_telemetry_points` returns an empty list, the API likely returned an un
|
|
|
281
294
|
|
|
282
295
|
### Rate limiting
|
|
283
296
|
|
|
284
|
-
The GDHV cloud API has rate limits.
|
|
297
|
+
The GDHV cloud API has rate limits. The library retries idempotent `GET`
|
|
298
|
+
requests automatically on HTTP 429/5xx and connection errors, using exponential
|
|
299
|
+
backoff with jitter and honouring the `Retry-After` header when present.
|
|
300
|
+
Non-idempotent control calls (`POST`/`PUT`/`PATCH`/`DELETE`) are **not** retried
|
|
301
|
+
by default. Tune this via the client constructor:
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
client = DimplexControl(
|
|
305
|
+
session,
|
|
306
|
+
refresh_token="...",
|
|
307
|
+
max_retries=3, # retries after the first attempt (0 disables)
|
|
308
|
+
retry_base_delay=0.5, # seconds; exponential base
|
|
309
|
+
retry_max_delay=8.0, # seconds; backoff ceiling
|
|
310
|
+
retry_non_idempotent=False, # set True to also retry POST/PUT/etc.
|
|
311
|
+
)
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
If you still hit persistent limits, back off for a few minutes before retrying.
|
|
285
315
|
|
|
286
316
|
### `get_appliance_overview` returns an empty list
|
|
287
317
|
|
|
@@ -99,17 +99,22 @@ if __name__ == "__main__":
|
|
|
99
99
|
|
|
100
100
|
### Authentication
|
|
101
101
|
|
|
102
|
-
Dimplex uses Azure AD B2C.
|
|
102
|
+
Dimplex uses Azure AD B2C. The library supports two methods:
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
#### Email / password (headless login) — recommended
|
|
105
105
|
|
|
106
|
-
```
|
|
107
|
-
|
|
106
|
+
```python
|
|
107
|
+
client = DimplexControl(session)
|
|
108
|
+
await client.auth.headless_login("you@example.com", "password")
|
|
108
109
|
```
|
|
109
110
|
|
|
110
|
-
|
|
111
|
+
This automates the full B2C flow via HTTP. On success, `client.is_authenticated` is `True` and tokens can be persisted with `client.export_tokens()`.
|
|
112
|
+
|
|
113
|
+
#### Manual auth code (browser)
|
|
114
|
+
|
|
115
|
+
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
116
|
|
|
112
|
-
|
|
117
|
+
Either way, refresh tokens are used on future calls — the library handles token renewal transparently.
|
|
113
118
|
|
|
114
119
|
### Discovery
|
|
115
120
|
|
|
@@ -188,22 +193,31 @@ See [docs/compatibility.md](docs/compatibility.md) for the library ↔ Home Assi
|
|
|
188
193
|
|
|
189
194
|
### `DimplexControl`
|
|
190
195
|
|
|
191
|
-
Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_token
|
|
196
|
+
Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_token` (or `token_bundle`).
|
|
192
197
|
|
|
193
198
|
| Method | Description |
|
|
194
199
|
|--------|-------------|
|
|
195
200
|
| `get_hubs()` | Returns `list[Hub]`. |
|
|
196
201
|
| `get_hub_zones(hub_id)` | Returns `list[Zone]` for a Hub. |
|
|
197
202
|
| `get_zone(hub_id, zone_id)` | Returns a single `Zone`. |
|
|
198
|
-
| `get_appliance_overview(hub_id, appliance_ids)` | Returns `list[ApplianceStatus]
|
|
203
|
+
| `get_appliance_overview(hub_id, appliance_ids)` | Returns `list[ApplianceStatus]` (may be `[]`). |
|
|
204
|
+
| `get_appliance_overview_map(hub_id, appliance_ids)` | Stable `dict[str, ApplianceStatus \| None]`. |
|
|
199
205
|
| `get_user_context()` | Returns `UserContext`. |
|
|
200
|
-
| `
|
|
201
|
-
| `
|
|
202
|
-
| `
|
|
203
|
-
| `
|
|
204
|
-
| `
|
|
205
|
-
| `
|
|
206
|
-
| `
|
|
206
|
+
| `get_product_models()` | Returns `list[ProductModel]` (cacheable). |
|
|
207
|
+
| `get_schedule(hub_id, appliance_id)` | Returns `TimerModeSettings` (timer + periods). |
|
|
208
|
+
| `set_mode(hub_id, appliance_id, mode)` | Change timer/operation mode. |
|
|
209
|
+
| `set_target_temperature(hub_id, appliance_id, temp)` | Rewrite all period setpoints or install full-week schedule. |
|
|
210
|
+
| `set_period_setpoint(...)` | Update one timer period without clobbering siblings. |
|
|
211
|
+
| `update_period(...)` | Replace a timer period matched by day + start time. |
|
|
212
|
+
| `set_boost(hub_id, appliance_ids, *, temperature, duration_minutes, enable)` | Enable/disable Boost. |
|
|
213
|
+
| `clear_boost(hub_id, appliance_ids)` | Disable Boost. |
|
|
214
|
+
| `set_away(hub_id, appliance_ids, *, temperature, enable, number_of_days)` | Enable/disable Away. |
|
|
215
|
+
| `clear_away(hub_id, appliance_ids)` | Disable Away. |
|
|
216
|
+
| `set_eco_start(hub_id, appliance_ids, enable)` | Toggle EcoStart. |
|
|
217
|
+
| `set_open_window_detection(hub_id, appliance_ids, enable)` | Toggle Open Window Detection. |
|
|
218
|
+
| `get_tsi_energy_report(hub_id, ...)` | Returns `TsiEnergyReport`. |
|
|
219
|
+
| `capabilities_for(appliance, *, status, product)` | Derive an `ApplianceCapabilities` matrix. |
|
|
220
|
+
| `export_tokens()` / `apply_tokens(bundle)` | Token persistence helpers. |
|
|
207
221
|
|
|
208
222
|
### Models
|
|
209
223
|
|
|
@@ -253,7 +267,24 @@ If `parse_telemetry_points` returns an empty list, the API likely returned an un
|
|
|
253
267
|
|
|
254
268
|
### Rate limiting
|
|
255
269
|
|
|
256
|
-
The GDHV cloud API has rate limits.
|
|
270
|
+
The GDHV cloud API has rate limits. The library retries idempotent `GET`
|
|
271
|
+
requests automatically on HTTP 429/5xx and connection errors, using exponential
|
|
272
|
+
backoff with jitter and honouring the `Retry-After` header when present.
|
|
273
|
+
Non-idempotent control calls (`POST`/`PUT`/`PATCH`/`DELETE`) are **not** retried
|
|
274
|
+
by default. Tune this via the client constructor:
|
|
275
|
+
|
|
276
|
+
```python
|
|
277
|
+
client = DimplexControl(
|
|
278
|
+
session,
|
|
279
|
+
refresh_token="...",
|
|
280
|
+
max_retries=3, # retries after the first attempt (0 disables)
|
|
281
|
+
retry_base_delay=0.5, # seconds; exponential base
|
|
282
|
+
retry_max_delay=8.0, # seconds; backoff ceiling
|
|
283
|
+
retry_non_idempotent=False, # set True to also retry POST/PUT/etc.
|
|
284
|
+
)
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
If you still hit persistent limits, back off for a few minutes before retrying.
|
|
257
288
|
|
|
258
289
|
### `get_appliance_overview` returns an empty list
|
|
259
290
|
|
|
@@ -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
|
|
@@ -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,7 @@ 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,
|
|
80
100
|
):
|
|
81
101
|
"""Initialize the client.
|
|
82
102
|
|
|
@@ -93,6 +113,12 @@ class DimplexControl:
|
|
|
93
113
|
policy (use with care).
|
|
94
114
|
* ``max_retries`` is the number of *retries* after the first attempt
|
|
95
115
|
(0 disables retries).
|
|
116
|
+
|
|
117
|
+
``timeout`` bounds every request (API and auth). Pass a number for a
|
|
118
|
+
total timeout in seconds (default 30s), an :class:`aiohttp.ClientTimeout`
|
|
119
|
+
for fine-grained control, or ``None`` to fall back to aiohttp defaults.
|
|
120
|
+
A timed-out request is surfaced as :class:`DimplexConnectionError` and,
|
|
121
|
+
for retryable methods, retried like any other connection error.
|
|
96
122
|
"""
|
|
97
123
|
if token_bundle is not None:
|
|
98
124
|
token_data: dict[str, Any] | TokenBundle = token_bundle
|
|
@@ -106,7 +132,8 @@ class DimplexControl:
|
|
|
106
132
|
token_data["expires_at"] = expires_at
|
|
107
133
|
|
|
108
134
|
self._session = session
|
|
109
|
-
self.
|
|
135
|
+
self._timeout = _coerce_timeout(timeout)
|
|
136
|
+
self.auth = AuthManager(session, token_data, timeout=self._timeout)
|
|
110
137
|
self._max_retries = max(0, int(max_retries))
|
|
111
138
|
self._retry_base_delay = float(retry_base_delay)
|
|
112
139
|
self._retry_max_delay = float(retry_max_delay)
|
|
@@ -170,6 +197,8 @@ class DimplexControl:
|
|
|
170
197
|
)
|
|
171
198
|
|
|
172
199
|
url = f"{BASE_URL}{endpoint}"
|
|
200
|
+
if self._timeout is not None and "timeout" not in kwargs:
|
|
201
|
+
kwargs["timeout"] = self._timeout
|
|
173
202
|
allow_retry = self._should_retry(method)
|
|
174
203
|
attempts = self._max_retries + 1 if allow_retry else 1
|
|
175
204
|
last_error: Exception | None = None
|
|
@@ -178,9 +207,7 @@ class DimplexControl:
|
|
|
178
207
|
try:
|
|
179
208
|
async with self._session.request(method, url, headers=headers, **kwargs) as resp:
|
|
180
209
|
if resp.status == HTTP_OK:
|
|
181
|
-
|
|
182
|
-
return {}
|
|
183
|
-
return await resp.json()
|
|
210
|
+
return await self._decode_ok_body(resp)
|
|
184
211
|
|
|
185
212
|
text = await resp.text()
|
|
186
213
|
retry_after = self._parse_retry_after(resp.headers.get("Retry-After"))
|
|
@@ -192,7 +219,7 @@ class DimplexControl:
|
|
|
192
219
|
endpoint,
|
|
193
220
|
resp.status,
|
|
194
221
|
attempt + 1,
|
|
195
|
-
|
|
222
|
+
attempts - 1,
|
|
196
223
|
delay,
|
|
197
224
|
)
|
|
198
225
|
last_error = DimplexApiError(resp.status, text)
|
|
@@ -201,7 +228,7 @@ class DimplexControl:
|
|
|
201
228
|
|
|
202
229
|
_LOGGER.error("API request failed: %s - %s", resp.status, text)
|
|
203
230
|
raise DimplexApiError(resp.status, text)
|
|
204
|
-
except aiohttp.ClientError as e:
|
|
231
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
205
232
|
if allow_retry and attempt + 1 < attempts:
|
|
206
233
|
delay = self._backoff_seconds(attempt)
|
|
207
234
|
_LOGGER.warning(
|
|
@@ -209,7 +236,7 @@ class DimplexControl:
|
|
|
209
236
|
method,
|
|
210
237
|
endpoint,
|
|
211
238
|
attempt + 1,
|
|
212
|
-
|
|
239
|
+
attempts - 1,
|
|
213
240
|
delay,
|
|
214
241
|
e,
|
|
215
242
|
)
|
|
@@ -225,6 +252,25 @@ class DimplexControl:
|
|
|
225
252
|
raise last_error
|
|
226
253
|
raise DimplexConnectionError("Request failed after retries")
|
|
227
254
|
|
|
255
|
+
@staticmethod
|
|
256
|
+
async def _decode_ok_body(resp: aiohttp.ClientResponse) -> Any:
|
|
257
|
+
"""Decode a 2xx JSON body, tolerating empty responses.
|
|
258
|
+
|
|
259
|
+
Some Dimplex control endpoints reply ``200 OK`` with an empty body (no
|
|
260
|
+
``Content-Length`` when chunked/compressed), so ``resp.content_length``
|
|
261
|
+
alone is unreliable. Read the raw text and treat empty/whitespace as an
|
|
262
|
+
empty object. A non-empty body that fails to parse is wrapped as a
|
|
263
|
+
:class:`DimplexConnectionError` rather than escaping as a raw
|
|
264
|
+
``JSONDecodeError``.
|
|
265
|
+
"""
|
|
266
|
+
text = await resp.text()
|
|
267
|
+
if not text or not text.strip():
|
|
268
|
+
return {}
|
|
269
|
+
try:
|
|
270
|
+
return json.loads(text)
|
|
271
|
+
except json.JSONDecodeError as exc:
|
|
272
|
+
raise DimplexConnectionError(f"Invalid JSON in response: {exc}") from exc
|
|
273
|
+
|
|
228
274
|
@staticmethod
|
|
229
275
|
def capabilities_for(
|
|
230
276
|
appliance: Appliance | None = None,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "dimplex-controller"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.11.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
|
|
File without changes
|