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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dimplex-controller
3
- Version: 0.10.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. You cannot log in purely programmatically with just a username and password — the library must first capture a short-lived authorisation code.
129
+ Dimplex uses Azure AD B2C. The library supports two methods:
131
130
 
132
- The recommended approach is to run the included demo script once:
131
+ #### Email / password (headless login) recommended
133
132
 
134
- ```bash
135
- python demo.py
133
+ ```python
134
+ client = DimplexControl(session)
135
+ await client.auth.headless_login("you@example.com", "password")
136
136
  ```
137
137
 
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.
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
- 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.
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
- | `get_appliance_features(hub_id, appliance_ids)` | Returns raw appliance feature data. |
227
- | `set_mode(hub_id, appliance_ids, mode, temperature)` | Set operation mode. |
228
- | `set_target_temperature(...)` | Placeholder for future target temperature control. |
229
- | `set_appliance_mode(hub_id, appliance_ids, settings)` | Send full mode settings. |
230
- | `set_eco_start(hub_id, appliance_ids, enabled)` | Toggle EcoStart. |
231
- | `set_open_window_detection(hub_id, appliance_ids, enabled)` | Toggle Open Window Detection. |
232
- | `get_tsi_energy_report(hub_id)` | Returns `TsiEnergyReport`. |
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. If you hit them, back off for a few minutes before retrying. The library does not currently implement automatic retries with back-off.
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. You cannot log in purely programmatically with just a username and password — the library must first capture a short-lived authorisation code.
102
+ Dimplex uses Azure AD B2C. The library supports two methods:
103
103
 
104
- The recommended approach is to run the included demo script once:
104
+ #### Email / password (headless login) recommended
105
105
 
106
- ```bash
107
- python demo.py
106
+ ```python
107
+ client = DimplexControl(session)
108
+ await client.auth.headless_login("you@example.com", "password")
108
109
  ```
109
110
 
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.
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
- 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.
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
- | `get_appliance_features(hub_id, appliance_ids)` | Returns raw appliance feature data. |
199
- | `set_mode(hub_id, appliance_ids, mode, temperature)` | Set operation mode. |
200
- | `set_target_temperature(...)` | Placeholder for future target temperature control. |
201
- | `set_appliance_mode(hub_id, appliance_ids, settings)` | Send full mode settings. |
202
- | `set_eco_start(hub_id, appliance_ids, enabled)` | Toggle EcoStart. |
203
- | `set_open_window_detection(hub_id, appliance_ids, enabled)` | Toggle Open Window Detection. |
204
- | `get_tsi_energy_report(hub_id)` | Returns `TsiEnergyReport`. |
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. If you hit them, back off for a few minutes before retrying. The library does not currently implement automatic retries with back-off.
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.auth = AuthManager(session, token_data)
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
- if resp.content_length == 0:
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
- self._max_retries,
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
- self._max_retries,
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.10.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"