dimplex-controller 0.10.0__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.0 → dimplex_controller-0.11.0}/PKG-INFO +54 -18
- {dimplex_controller-0.10.0 → dimplex_controller-0.11.0}/README.md +53 -16
- {dimplex_controller-0.10.0 → dimplex_controller-0.11.0}/dimplex_controller/__init__.py +12 -1
- {dimplex_controller-0.10.0 → dimplex_controller-0.11.0}/dimplex_controller/auth.py +29 -6
- {dimplex_controller-0.10.0 → dimplex_controller-0.11.0}/dimplex_controller/client.py +53 -7
- {dimplex_controller-0.10.0 → dimplex_controller-0.11.0}/pyproject.toml +15 -2
- {dimplex_controller-0.10.0 → dimplex_controller-0.11.0}/LICENSE +0 -0
- {dimplex_controller-0.10.0 → dimplex_controller-0.11.0}/dimplex_controller/capabilities.py +0 -0
- {dimplex_controller-0.10.0 → dimplex_controller-0.11.0}/dimplex_controller/cli.py +0 -0
- {dimplex_controller-0.10.0 → dimplex_controller-0.11.0}/dimplex_controller/const.py +0 -0
- {dimplex_controller-0.10.0 → dimplex_controller-0.11.0}/dimplex_controller/exceptions.py +0 -0
- {dimplex_controller-0.10.0 → dimplex_controller-0.11.0}/dimplex_controller/models.py +0 -0
- {dimplex_controller-0.10.0 → dimplex_controller-0.11.0}/dimplex_controller/py.typed +0 -0
- {dimplex_controller-0.10.0 → 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)
|
|
139
141
|
|
|
140
|
-
|
|
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.
|
|
143
|
+
|
|
144
|
+
Either way, refresh tokens are used on future calls — the library handles token renewal transparently.
|
|
141
145
|
|
|
142
146
|
### Discovery
|
|
143
147
|
|
|
@@ -167,6 +171,8 @@ for status in status_list:
|
|
|
167
171
|
print(f"Comfort status: {status.ComfortStatus}")
|
|
168
172
|
```
|
|
169
173
|
|
|
174
|
+
> **A note on empty responses:** when every requested appliance is offline (e.g. radiators switched off at the wall) the cloud returns HTTP 200 with an empty list. `get_appliance_overview` surfaces that as `[]` — it is **not** an error. If you need a stable id → status mapping, use `get_appliance_overview_map(...)`, which fills in `None` for missing ids.
|
|
175
|
+
|
|
170
176
|
### Sending control commands
|
|
171
177
|
|
|
172
178
|
```python
|
|
@@ -214,22 +220,31 @@ See [docs/compatibility.md](docs/compatibility.md) for the library ↔ Home Assi
|
|
|
214
220
|
|
|
215
221
|
### `DimplexControl`
|
|
216
222
|
|
|
217
|
-
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`).
|
|
218
224
|
|
|
219
225
|
| Method | Description |
|
|
220
226
|
|--------|-------------|
|
|
221
227
|
| `get_hubs()` | Returns `list[Hub]`. |
|
|
222
228
|
| `get_hub_zones(hub_id)` | Returns `list[Zone]` for a Hub. |
|
|
223
229
|
| `get_zone(hub_id, zone_id)` | Returns a single `Zone`. |
|
|
224
|
-
| `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]`. |
|
|
225
232
|
| `get_user_context()` | Returns `UserContext`. |
|
|
226
|
-
| `
|
|
227
|
-
| `
|
|
228
|
-
| `
|
|
229
|
-
| `
|
|
230
|
-
| `
|
|
231
|
-
| `
|
|
232
|
-
| `
|
|
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. |
|
|
233
248
|
|
|
234
249
|
### Models
|
|
235
250
|
|
|
@@ -279,7 +294,28 @@ If `parse_telemetry_points` returns an empty list, the API likely returned an un
|
|
|
279
294
|
|
|
280
295
|
### Rate limiting
|
|
281
296
|
|
|
282
|
-
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.
|
|
315
|
+
|
|
316
|
+
### `get_appliance_overview` returns an empty list
|
|
317
|
+
|
|
318
|
+
This is the cloud's normal response when every requested appliance is offline (e.g. radiators turned off at the wall, or a hub that has dropped off the network). It is **not** an error — `get_appliance_overview` returns `[]` and `get_appliance_overview_map` returns a dict of `None` values. Treat the call as a successful poll; the appliances will reappear in subsequent calls once they come back online. See the note in [Reading status](#reading-status) for details.
|
|
283
319
|
|
|
284
320
|
|
|
285
321
|
## CLI
|
|
@@ -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)
|
|
111
114
|
|
|
112
|
-
|
|
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.
|
|
116
|
+
|
|
117
|
+
Either way, refresh tokens are used on future calls — the library handles token renewal transparently.
|
|
113
118
|
|
|
114
119
|
### Discovery
|
|
115
120
|
|
|
@@ -139,6 +144,8 @@ for status in status_list:
|
|
|
139
144
|
print(f"Comfort status: {status.ComfortStatus}")
|
|
140
145
|
```
|
|
141
146
|
|
|
147
|
+
> **A note on empty responses:** when every requested appliance is offline (e.g. radiators switched off at the wall) the cloud returns HTTP 200 with an empty list. `get_appliance_overview` surfaces that as `[]` — it is **not** an error. If you need a stable id → status mapping, use `get_appliance_overview_map(...)`, which fills in `None` for missing ids.
|
|
148
|
+
|
|
142
149
|
### Sending control commands
|
|
143
150
|
|
|
144
151
|
```python
|
|
@@ -186,22 +193,31 @@ See [docs/compatibility.md](docs/compatibility.md) for the library ↔ Home Assi
|
|
|
186
193
|
|
|
187
194
|
### `DimplexControl`
|
|
188
195
|
|
|
189
|
-
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`).
|
|
190
197
|
|
|
191
198
|
| Method | Description |
|
|
192
199
|
|--------|-------------|
|
|
193
200
|
| `get_hubs()` | Returns `list[Hub]`. |
|
|
194
201
|
| `get_hub_zones(hub_id)` | Returns `list[Zone]` for a Hub. |
|
|
195
202
|
| `get_zone(hub_id, zone_id)` | Returns a single `Zone`. |
|
|
196
|
-
| `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]`. |
|
|
197
205
|
| `get_user_context()` | Returns `UserContext`. |
|
|
198
|
-
| `
|
|
199
|
-
| `
|
|
200
|
-
| `
|
|
201
|
-
| `
|
|
202
|
-
| `
|
|
203
|
-
| `
|
|
204
|
-
| `
|
|
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. |
|
|
205
221
|
|
|
206
222
|
### Models
|
|
207
223
|
|
|
@@ -251,7 +267,28 @@ If `parse_telemetry_points` returns an empty list, the API likely returned an un
|
|
|
251
267
|
|
|
252
268
|
### Rate limiting
|
|
253
269
|
|
|
254
|
-
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.
|
|
288
|
+
|
|
289
|
+
### `get_appliance_overview` returns an empty list
|
|
290
|
+
|
|
291
|
+
This is the cloud's normal response when every requested appliance is offline (e.g. radiators turned off at the wall, or a hub that has dropped off the network). It is **not** an error — `get_appliance_overview` returns `[]` and `get_appliance_overview_map` returns a dict of `None` values. Treat the call as a successful poll; the appliances will reappear in subsequent calls once they come back online. See the note in [Reading status](#reading-status) for details.
|
|
255
292
|
|
|
256
293
|
|
|
257
294
|
## CLI
|
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
"""Dimplex Controller Client.
|
|
1
|
+
"""Dimplex Controller Client.
|
|
2
|
+
|
|
3
|
+
Async Python client for the Glen Dimplex Heating & Ventilation (GDHV) cloud
|
|
4
|
+
API. See :class:`~dimplex_controller.client.DimplexControl` for the entry
|
|
5
|
+
point and :meth:`DimplexControl.get_appliance_overview` for the read path
|
|
6
|
+
used by the Home Assistant integration.
|
|
7
|
+
|
|
8
|
+
A note on the API: ``get_appliance_overview`` may return an empty list
|
|
9
|
+
with HTTP 200 when the requested appliances are offline. That is a
|
|
10
|
+
successful poll, not an error — use ``get_appliance_overview_map`` if you
|
|
11
|
+
need a stable id → status mapping.
|
|
12
|
+
"""
|
|
2
13
|
|
|
3
14
|
from .auth import TokenBundle
|
|
4
15
|
from .capabilities import ApplianceCapabilities, capabilities_for
|
|
@@ -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
|