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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dimplex-controller
3
- Version: 0.10.1
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
  [![PyPI version](https://img.shields.io/pypi/v/dimplex-controller.svg)](https://pypi.org/project/dimplex-controller/)
32
31
  [![Python versions](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
33
32
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
34
- [![CI Tests](https://github.com/KRoperUK/dimplex-controller-py/actions/workflows/tests.yml/badge.svg)](https://github.com/KRoperUK/dimplex-controller-py/actions)
33
+ [![CI Tests](https://img.shields.io/github/actions/workflow/status/KRoperUK/dimplex-controller-py/test.yml?branch=main&label=CI)](https://github.com/KRoperUK/dimplex-controller-py/actions/workflows/test.yml)
35
34
  [![Downloads](https://img.shields.io/pypi/dm/dimplex-controller.svg)](https://pypi.org/project/dimplex-controller/)
35
+ [![Buy me a coffee](https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg)](https://buymeacoffee.com/kroperukc)
36
36
 
37
37
  <p align="center">
38
38
  <strong>Async Python client for controlling Glen Dimplex Heating &amp; 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. You cannot log in purely programmatically with just a username and password — the library must first capture a short-lived authorisation code.
130
+ Dimplex uses Azure AD B2C. The library supports two methods:
131
131
 
132
- The recommended approach is to run the included demo script once:
132
+ #### Email / password (headless login) recommended
133
133
 
134
- ```bash
135
- python demo.py
134
+ ```python
135
+ client = DimplexControl(session)
136
+ await client.auth.headless_login("you@example.com", "password")
136
137
  ```
137
138
 
138
- The script walks you through opening a browser, signing in, and pasting the resulting redirect URL back into the terminal. On success, it saves a `dimplex_tokens.json` file in the current working directory.
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
- Subsequent runs read the refresh token from that file automatically. You can also pass the refresh token directly to `DimplexControl` if you prefer to manage storage yourself.
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
- | `get_appliance_features(hub_id, appliance_ids)` | Returns raw appliance feature data. |
229
- | `set_mode(hub_id, appliance_ids, mode, temperature)` | Set operation mode. |
230
- | `set_target_temperature(...)` | Placeholder for future target temperature control. |
231
- | `set_appliance_mode(hub_id, appliance_ids, settings)` | Send full mode settings. |
232
- | `set_eco_start(hub_id, appliance_ids, enabled)` | Toggle EcoStart. |
233
- | `set_open_window_detection(hub_id, appliance_ids, enabled)` | Toggle Open Window Detection. |
234
- | `get_tsi_energy_report(hub_id)` | Returns `TsiEnergyReport`. |
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. If you hit them, back off for a few minutes before retrying. The library does not currently implement automatic retries with back-off.
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
  [![PyPI version](https://img.shields.io/pypi/v/dimplex-controller.svg)](https://pypi.org/project/dimplex-controller/)
4
4
  [![Python versions](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
- [![CI Tests](https://github.com/KRoperUK/dimplex-controller-py/actions/workflows/tests.yml/badge.svg)](https://github.com/KRoperUK/dimplex-controller-py/actions)
6
+ [![CI Tests](https://img.shields.io/github/actions/workflow/status/KRoperUK/dimplex-controller-py/test.yml?branch=main&label=CI)](https://github.com/KRoperUK/dimplex-controller-py/actions/workflows/test.yml)
7
7
  [![Downloads](https://img.shields.io/pypi/dm/dimplex-controller.svg)](https://pypi.org/project/dimplex-controller/)
8
+ [![Buy me a coffee](https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg)](https://buymeacoffee.com/kroperukc)
8
9
 
9
10
  <p align="center">
10
11
  <strong>Async Python client for controlling Glen Dimplex Heating &amp; 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. You cannot log in purely programmatically with just a username and password — the library must first capture a short-lived authorisation code.
103
+ Dimplex uses Azure AD B2C. The library supports two methods:
103
104
 
104
- The recommended approach is to run the included demo script once:
105
+ #### Email / password (headless login) recommended
105
106
 
106
- ```bash
107
- python demo.py
107
+ ```python
108
+ client = DimplexControl(session)
109
+ await client.auth.headless_login("you@example.com", "password")
108
110
  ```
109
111
 
110
- The script walks you through opening a browser, signing in, and pasting the resulting redirect URL back into the terminal. On success, it saves a `dimplex_tokens.json` file in the current working directory.
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
- Subsequent runs read the refresh token from that file automatically. You can also pass the refresh token directly to `DimplexControl` if you prefer to manage storage yourself.
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
- | `get_appliance_features(hub_id, appliance_ids)` | Returns raw appliance feature data. |
201
- | `set_mode(hub_id, appliance_ids, mode, temperature)` | Set operation mode. |
202
- | `set_target_temperature(...)` | Placeholder for future target temperature control. |
203
- | `set_appliance_mode(hub_id, appliance_ids, settings)` | Send full mode settings. |
204
- | `set_eco_start(hub_id, appliance_ids, enabled)` | Toggle EcoStart. |
205
- | `set_open_window_detection(hub_id, appliance_ids, enabled)` | Toggle Open Window Detection. |
206
- | `get_tsi_energy_report(hub_id)` | Returns `TsiEnergyReport`. |
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. If you hit them, back off for a few minutes before retrying. The library does not currently implement automatic retries with back-off.
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.auth = AuthManager(session, token_data)
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
- if resp.content_length == 0:
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
- self._max_retries,
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
- self._max_retries,
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.10.1"
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"