dimplex-controller 0.12.0__tar.gz → 0.13.1__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,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dimplex-controller
3
- Version: 0.12.0
4
- Summary: Python client for Dimplex heating controllers (GDHV IoT)
3
+ Version: 0.13.1
4
+ Summary: Unofficial async Python client for Dimplex (GDHV IoT) heating appliances — not affiliated with Glen Dimplex
5
5
  License: MIT
6
6
  License-File: LICENSE
7
7
  Keywords: dimplex,heating,control,iot,asyncio
@@ -27,12 +27,12 @@ Description-Content-Type: text/markdown
27
27
 
28
28
  # Dimplex Controller Python Client
29
29
 
30
- [![PyPI version](https://img.shields.io/pypi/v/dimplex-controller.svg)](https://pypi.org/project/dimplex-controller/)
31
- [![Python versions](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
30
+ [![PyPI version](https://img.shields.io/pypi/v/dimplex-controller)](https://pypi.org/project/dimplex-controller/)
31
+ [![Python versions](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
32
32
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
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)
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)
33
+ [![CI](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)
34
+ [![Downloads](https://img.shields.io/pypi/dm/dimplex-controller)](https://pypi.org/project/dimplex-controller/)
35
+ [![Buy me a coffee](https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow)](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>
@@ -40,14 +40,24 @@ Description-Content-Type: text/markdown
40
40
 
41
41
  ---
42
42
 
43
+ > [!IMPORTANT]
44
+ > **Unofficial library.** Not affiliated with, endorsed by, or supported by Dimplex, Glen
45
+ > Dimplex Heating & Ventilation (GDHV), or the Glen Dimplex Group. This is an independent
46
+ > project built by **reverse-engineering** the official Dimplex Control Android app and the
47
+ > private cloud API it talks to.
48
+ >
49
+ > There is no public or documented API. Dimplex can change or withdraw that API at any time
50
+ > and without notice, which may break this library. Use it at your own risk.
51
+ >
52
+ > No Dimplex code is included or redistributed. "Dimplex", "Quantum", "QRAD" and related
53
+ > marks belong to their respective owners and are used here only to describe compatibility.
54
+
43
55
  ## What does this do?
44
56
 
45
57
  `dimplex-controller-py` is an asynchronous Python client that talks to the GDHV IoT cloud platform. It handles Azure B2C authentication (including automatic token refresh), discovers your Hubs, Zones and Appliances, and lets you read telemetry and send control commands — all from a script or a larger application.
46
58
 
47
59
  It is the engine behind the [Dimplex Hub Home Assistant integration](https://github.com/KRoperUK/dimplex-controller-hass) and is published to PyPI as [`dimplex-controller`](https://pypi.org/project/dimplex-controller/).
48
60
 
49
- > **Note:** This is an unofficial library and is not affiliated with or endorsed by Glen Dimplex Heating & Ventilation (GDHV). Use it at your own risk.
50
-
51
61
  ## Contents
52
62
 
53
63
  - [Features](#features)
@@ -177,21 +187,45 @@ for status in status_list:
177
187
  ### Sending control commands
178
188
 
179
189
  ```python
180
- from dimplex_controller.models import ApplianceModeSettings
181
-
182
190
  # Enable EcoStart
183
191
  await client.set_eco_start(hub_id, [appliance_id], True)
184
192
 
185
193
  # Enable Open Window Detection
186
194
  await client.set_open_window_detection(hub_id, [appliance_id], True)
187
195
 
188
- # Activate Boost
196
+ # Timed Boost (ApplianceModes=2, Time = minutes)
189
197
  await client.set_boost(hub_id, [appliance_id], temperature=25.0, duration_minutes=60)
190
198
 
191
- # Set target temperature (rewrites timer period setpoints)
192
- await client.set_target_temperature(hub_id, appliance_id, 21.5)
199
+ # Away until a given moment (ApplianceModes=4). Away is a settable 7–18 °C
200
+ # setback and defaults to the 7 °C anti-freeze floor. A higher temperature is
201
+ # clamped to 18 °C with a warning, matching what the cloud does to it anyway.
202
+ from datetime import datetime, timedelta, timezone
203
+
204
+ await client.set_away(
205
+ hub_id,
206
+ [appliance_id],
207
+ temperature=12.0,
208
+ until=datetime.now(timezone.utc) + timedelta(days=3),
209
+ )
210
+
211
+ # Set the target temperature — dedicated endpoint, leaves the schedule alone
212
+ await client.set_appliance_setpoint_temperature(hub_id, [appliance_id], 21.5)
213
+
214
+ # Turn off the way the app does: frost protection at 7 °C
215
+ await client.turn_off(hub_id, [appliance_id])
193
216
  ```
194
217
 
218
+ > **Mode flag values matter.** `EApplianceModes` is a bitfield where Boost is `2`
219
+ > and Away is `4`; `16` is Advance and `32` is FrostProtect. Releases before
220
+ > 0.13.0 had these wrong, so Boost silently became Advance and Away became a
221
+ > fixed 7 °C frost hold. If you hand-build `ApplianceModeSettings`, use
222
+ > `ApplianceModeFlag`. See
223
+ > [docs/decompiled-api-reference.md](docs/decompiled-api-reference.md).
224
+ >
225
+ > **Avoid `SetTimerMode` for control.** `set_mode()` and the deprecated
226
+ > `set_target_temperature()` rewrite the schedule; Quantum rejects that with
227
+ > HTTP 403. Use `set_appliance_setpoint_temperature()` and `turn_off()`.
228
+
195
229
  ### Energy reports
196
230
 
197
231
  ```python
@@ -233,16 +267,23 @@ Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_toke
233
267
  | `get_user_context()` | Returns `UserContext`. |
234
268
  | `get_product_models()` | Returns `list[ProductModel]` (cacheable). |
235
269
  | `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. |
270
+ | `set_mode(hub_id, appliance_id, mode)` | Rewrite `TimerMode`. **403 on Quantum** — prefer `turn_off`. |
271
+ | `set_target_temperature(hub_id, appliance_id, temp)` | Deprecated: rewrites period setpoints. Prefer `set_appliance_setpoint_temperature`. |
272
+ | `set_appliance_setpoint_temperature(hub_id, appliance_ids, temperature)` | Preferred setpoint path; non-destructive. |
238
273
  | `set_period_setpoint(...)` | Update one timer period without clobbering siblings. |
239
274
  | `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. |
275
+ | `copy_schedule_to_appliances(...)` | Copy one appliance's schedule onto others. |
276
+ | `set_boost(hub_id, appliance_ids, *, temperature, duration_minutes, enable)` | Timed Boost (`ApplianceModes=2`). |
241
277
  | `clear_boost(hub_id, appliance_ids)` | Disable Boost. |
242
- | `set_away(hub_id, appliance_ids, *, temperature, enable, number_of_days)` | Enable/disable Away. |
278
+ | `set_away(hub_id, appliance_ids, *, temperature, enable, until, number_of_days)` | Away setback (`ApplianceModes=4`). |
243
279
  | `clear_away(hub_id, appliance_ids)` | Disable Away. |
280
+ | `set_frost_protect(...)` / `turn_off(...)` | Frost protection at 7 °C — the app's "off". |
281
+ | `set_advance(...)` | Advance to the next schedule period. |
282
+ | `set_manual(...)` / `set_eco_mode(...)` | Manual / Eco mode holds. |
283
+ | `set_setback_temperature(...)` | Write the setback temperature (untested). |
244
284
  | `set_eco_start(hub_id, appliance_ids, enable)` | Toggle EcoStart. |
245
285
  | `set_open_window_detection(hub_id, appliance_ids, enable)` | Toggle Open Window Detection. |
286
+ | `set_hot_water_*(...)` / `*_heat_pump_hot_water_schedule(...)` | Hot-water cylinder surface (untested — no hardware). |
246
287
  | `get_tsi_energy_report(hub_id, ...)` | Returns `TsiEnergyReport`. |
247
288
  | `capabilities_for(appliance, *, status, product)` | Derive an `ApplianceCapabilities` matrix. |
248
289
  | `export_tokens()` / `apply_tokens(bundle)` | Token persistence helpers. |
@@ -1,11 +1,11 @@
1
1
  # Dimplex Controller Python Client
2
2
 
3
- [![PyPI version](https://img.shields.io/pypi/v/dimplex-controller.svg)](https://pypi.org/project/dimplex-controller/)
4
- [![Python versions](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
3
+ [![PyPI version](https://img.shields.io/pypi/v/dimplex-controller)](https://pypi.org/project/dimplex-controller/)
4
+ [![Python versions](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
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
- [![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)
6
+ [![CI](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
+ [![Downloads](https://img.shields.io/pypi/dm/dimplex-controller)](https://pypi.org/project/dimplex-controller/)
8
+ [![Buy me a coffee](https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow)](https://buymeacoffee.com/kroperukc)
9
9
 
10
10
  <p align="center">
11
11
  <strong>Async Python client for controlling Glen Dimplex Heating &amp; Ventilation (GDHV) appliances via the Dimplex cloud API.</strong>
@@ -13,14 +13,24 @@
13
13
 
14
14
  ---
15
15
 
16
+ > [!IMPORTANT]
17
+ > **Unofficial library.** Not affiliated with, endorsed by, or supported by Dimplex, Glen
18
+ > Dimplex Heating & Ventilation (GDHV), or the Glen Dimplex Group. This is an independent
19
+ > project built by **reverse-engineering** the official Dimplex Control Android app and the
20
+ > private cloud API it talks to.
21
+ >
22
+ > There is no public or documented API. Dimplex can change or withdraw that API at any time
23
+ > and without notice, which may break this library. Use it at your own risk.
24
+ >
25
+ > No Dimplex code is included or redistributed. "Dimplex", "Quantum", "QRAD" and related
26
+ > marks belong to their respective owners and are used here only to describe compatibility.
27
+
16
28
  ## What does this do?
17
29
 
18
30
  `dimplex-controller-py` is an asynchronous Python client that talks to the GDHV IoT cloud platform. It handles Azure B2C authentication (including automatic token refresh), discovers your Hubs, Zones and Appliances, and lets you read telemetry and send control commands — all from a script or a larger application.
19
31
 
20
32
  It is the engine behind the [Dimplex Hub Home Assistant integration](https://github.com/KRoperUK/dimplex-controller-hass) and is published to PyPI as [`dimplex-controller`](https://pypi.org/project/dimplex-controller/).
21
33
 
22
- > **Note:** This is an unofficial library and is not affiliated with or endorsed by Glen Dimplex Heating & Ventilation (GDHV). Use it at your own risk.
23
-
24
34
  ## Contents
25
35
 
26
36
  - [Features](#features)
@@ -150,21 +160,45 @@ for status in status_list:
150
160
  ### Sending control commands
151
161
 
152
162
  ```python
153
- from dimplex_controller.models import ApplianceModeSettings
154
-
155
163
  # Enable EcoStart
156
164
  await client.set_eco_start(hub_id, [appliance_id], True)
157
165
 
158
166
  # Enable Open Window Detection
159
167
  await client.set_open_window_detection(hub_id, [appliance_id], True)
160
168
 
161
- # Activate Boost
169
+ # Timed Boost (ApplianceModes=2, Time = minutes)
162
170
  await client.set_boost(hub_id, [appliance_id], temperature=25.0, duration_minutes=60)
163
171
 
164
- # Set target temperature (rewrites timer period setpoints)
165
- await client.set_target_temperature(hub_id, appliance_id, 21.5)
172
+ # Away until a given moment (ApplianceModes=4). Away is a settable 7–18 °C
173
+ # setback and defaults to the 7 °C anti-freeze floor. A higher temperature is
174
+ # clamped to 18 °C with a warning, matching what the cloud does to it anyway.
175
+ from datetime import datetime, timedelta, timezone
176
+
177
+ await client.set_away(
178
+ hub_id,
179
+ [appliance_id],
180
+ temperature=12.0,
181
+ until=datetime.now(timezone.utc) + timedelta(days=3),
182
+ )
183
+
184
+ # Set the target temperature — dedicated endpoint, leaves the schedule alone
185
+ await client.set_appliance_setpoint_temperature(hub_id, [appliance_id], 21.5)
186
+
187
+ # Turn off the way the app does: frost protection at 7 °C
188
+ await client.turn_off(hub_id, [appliance_id])
166
189
  ```
167
190
 
191
+ > **Mode flag values matter.** `EApplianceModes` is a bitfield where Boost is `2`
192
+ > and Away is `4`; `16` is Advance and `32` is FrostProtect. Releases before
193
+ > 0.13.0 had these wrong, so Boost silently became Advance and Away became a
194
+ > fixed 7 °C frost hold. If you hand-build `ApplianceModeSettings`, use
195
+ > `ApplianceModeFlag`. See
196
+ > [docs/decompiled-api-reference.md](docs/decompiled-api-reference.md).
197
+ >
198
+ > **Avoid `SetTimerMode` for control.** `set_mode()` and the deprecated
199
+ > `set_target_temperature()` rewrite the schedule; Quantum rejects that with
200
+ > HTTP 403. Use `set_appliance_setpoint_temperature()` and `turn_off()`.
201
+
168
202
  ### Energy reports
169
203
 
170
204
  ```python
@@ -206,16 +240,23 @@ Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_toke
206
240
  | `get_user_context()` | Returns `UserContext`. |
207
241
  | `get_product_models()` | Returns `list[ProductModel]` (cacheable). |
208
242
  | `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. |
243
+ | `set_mode(hub_id, appliance_id, mode)` | Rewrite `TimerMode`. **403 on Quantum** — prefer `turn_off`. |
244
+ | `set_target_temperature(hub_id, appliance_id, temp)` | Deprecated: rewrites period setpoints. Prefer `set_appliance_setpoint_temperature`. |
245
+ | `set_appliance_setpoint_temperature(hub_id, appliance_ids, temperature)` | Preferred setpoint path; non-destructive. |
211
246
  | `set_period_setpoint(...)` | Update one timer period without clobbering siblings. |
212
247
  | `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. |
248
+ | `copy_schedule_to_appliances(...)` | Copy one appliance's schedule onto others. |
249
+ | `set_boost(hub_id, appliance_ids, *, temperature, duration_minutes, enable)` | Timed Boost (`ApplianceModes=2`). |
214
250
  | `clear_boost(hub_id, appliance_ids)` | Disable Boost. |
215
- | `set_away(hub_id, appliance_ids, *, temperature, enable, number_of_days)` | Enable/disable Away. |
251
+ | `set_away(hub_id, appliance_ids, *, temperature, enable, until, number_of_days)` | Away setback (`ApplianceModes=4`). |
216
252
  | `clear_away(hub_id, appliance_ids)` | Disable Away. |
253
+ | `set_frost_protect(...)` / `turn_off(...)` | Frost protection at 7 °C — the app's "off". |
254
+ | `set_advance(...)` | Advance to the next schedule period. |
255
+ | `set_manual(...)` / `set_eco_mode(...)` | Manual / Eco mode holds. |
256
+ | `set_setback_temperature(...)` | Write the setback temperature (untested). |
217
257
  | `set_eco_start(hub_id, appliance_ids, enable)` | Toggle EcoStart. |
218
258
  | `set_open_window_detection(hub_id, appliance_ids, enable)` | Toggle Open Window Detection. |
259
+ | `set_hot_water_*(...)` / `*_heat_pump_hot_water_schedule(...)` | Hot-water cylinder surface (untested — no hardware). |
219
260
  | `get_tsi_energy_report(hub_id, ...)` | Returns `TsiEnergyReport`. |
220
261
  | `capabilities_for(appliance, *, status, product)` | Derive an `ApplianceCapabilities` matrix. |
221
262
  | `export_tokens()` / `apply_tokens(bundle)` | Token persistence helpers. |
@@ -1,7 +1,10 @@
1
1
  """Dimplex Controller Client.
2
2
 
3
- Async Python client for the Glen Dimplex Heating & Ventilation (GDHV) cloud
4
- API. See :class:`~dimplex_controller.client.DimplexControl` for the entry
3
+ Unofficial async Python client for the Glen Dimplex Heating & Ventilation
4
+ (GDHV) cloud API. Not affiliated with, endorsed by, or supported by Dimplex
5
+ or the Glen Dimplex Group: the protocol was recovered by reverse-engineering
6
+ the official Dimplex Control Android app, so Dimplex may change or withdraw
7
+ it without notice. See :class:`~dimplex_controller.client.DimplexControl` for the entry
5
8
  point and :meth:`DimplexControl.get_appliance_overview` for the read path
6
9
  used by the Home Assistant integration.
7
10
 
@@ -14,6 +17,17 @@ need a stable id → status mapping.
14
17
  from .auth import TokenBundle, TokenListener
15
18
  from .capabilities import ApplianceCapabilities, capabilities_for
16
19
  from .client import DimplexControl
20
+ from .const import (
21
+ AWAY_TEMP_MAX,
22
+ AWAY_TEMP_MIN,
23
+ DEFAULT_AWAY_TEMPERATURE,
24
+ DEFAULT_BOOST_TEMPERATURE,
25
+ FROST_TEMPERATURE,
26
+ MODE_TEMP_MAX,
27
+ MODE_TEMP_MIN,
28
+ NO_SETPOINT_SENTINEL,
29
+ NULL_DATETIME,
30
+ )
17
31
  from .exceptions import (
18
32
  DimplexApiError,
19
33
  DimplexAuthError,
@@ -28,10 +42,14 @@ from .models import (
28
42
  Appliance,
29
43
  ApplianceModeFlag,
30
44
  ApplianceModeSettings,
45
+ ApplianceModeStatus,
31
46
  ApplianceStatus,
32
47
  AutomaticProvisioning,
33
48
  Hub,
49
+ HygieneFrequency,
34
50
  ProductModel,
51
+ ScheduleProfile,
52
+ SetbackStatus,
35
53
  TimerMode,
36
54
  TimerModeSettings,
37
55
  TimerPeriod,
@@ -59,12 +77,25 @@ __all__ = [
59
77
  "ApplianceStatus",
60
78
  "ApplianceModeSettings",
61
79
  "ApplianceModeFlag",
80
+ "ApplianceModeStatus",
81
+ "HygieneFrequency",
82
+ "ScheduleProfile",
83
+ "SetbackStatus",
62
84
  "TimerMode",
63
85
  "TimerModeSettings",
64
86
  "TimerPeriod",
65
87
  "AutomaticProvisioning",
66
88
  "ProductModel",
67
89
  "TsiEnergyReport",
90
+ "DEFAULT_AWAY_TEMPERATURE",
91
+ "DEFAULT_BOOST_TEMPERATURE",
92
+ "AWAY_TEMP_MIN",
93
+ "AWAY_TEMP_MAX",
94
+ "FROST_TEMPERATURE",
95
+ "MODE_TEMP_MIN",
96
+ "MODE_TEMP_MAX",
97
+ "NO_SETPOINT_SENTINEL",
98
+ "NULL_DATETIME",
68
99
  "parse_telemetry_points",
69
100
  "filter_telemetry_points",
70
101
  "summarise_energy",
@@ -309,12 +309,23 @@ class AuthManager:
309
309
 
310
310
  Uses direct HTTP credential submission so users don't need to
311
311
  manually extract auth codes from browser network traffic.
312
+
313
+ A separate :class:`aiohttp.ClientSession` is used because the B2C
314
+ flow requires a dedicated cookie jar for state tracking across
315
+ redirects (using the shared session would pollute its cookies).
316
+ The session inherits the caller's **connector** (TLS/proxy config)
317
+ and the configured timeout so network behaviour is consistent.
312
318
  """
313
319
  jar = aiohttp.CookieJar(unsafe=True)
314
320
  start_url = self.get_login_url()
321
+ # Inherit the caller's connector (TLS cert, proxy, DNS resolver) so
322
+ # headless login respects the same network configuration as API calls.
323
+ connector = self._session.connector
315
324
 
316
325
  try:
317
- async with aiohttp.ClientSession(cookie_jar=jar, timeout=self._timeout) as session:
326
+ async with aiohttp.ClientSession(
327
+ cookie_jar=jar, timeout=self._timeout, connector=connector, connector_owner=False
328
+ ) as session:
318
329
  # Step 1: GET the auth URI, follow redirects to B2C login page
319
330
  _LOGGER.debug("Fetching B2C login page")
320
331
  try:
@@ -374,6 +385,9 @@ class AuthManager:
374
385
  # re-injected with quoted values on the next request.
375
386
  async with aiohttp.ClientSession(
376
387
  cookie_jar=aiohttp.DummyCookieJar(),
388
+ timeout=self._timeout,
389
+ connector=connector,
390
+ connector_owner=False,
377
391
  ) as raw_session:
378
392
  try:
379
393
  async with raw_session.post(
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  from dataclasses import dataclass
10
10
  from typing import Any
11
11
 
12
+ from .const import AWAY_TEMP_MAX, AWAY_TEMP_MIN, FROST_TEMPERATURE, MODE_TEMP_MAX, MODE_TEMP_MIN
12
13
  from .models import Appliance, ApplianceStatus, AutomaticProvisioning, ProductModel
13
14
 
14
15
  # Default boost lengths (minutes) offered by the mobile app for most heaters.
@@ -27,18 +28,31 @@ class ApplianceCapabilities:
27
28
 
28
29
  boost: bool = True
29
30
  away: bool = True
31
+ advance: bool = True
30
32
  open_window: bool = True
31
33
  eco_start: bool = True
32
34
  setback_read: bool = True
33
- setback_write: bool = False # no confirmed write API yet
34
- frost: bool = True # TimerMode.FROST_PROTECTION
35
+ setback_write: bool = True # POST /RemoteControl/SetSetbackTemperature
36
+ frost: bool = True # ApplianceModeFlag.FROST_PROTECT (the app's "off")
35
37
  timer: bool = True
38
+ # POST /RemoteControl/SetApplianceSetpointTemperature — the non-destructive
39
+ # setpoint path. Preferred over rewriting the schedule via SetTimerMode,
40
+ # which Quantum rejects with HTTP 403.
41
+ setpoint_write: bool = True
36
42
  energy_meter: bool = False
37
43
  storage: bool = False
38
44
  hot_water: bool = False
45
+ heat_pump: bool = False
46
+ hygiene: bool = False
39
47
  climate: bool = True
40
- min_temp: float = 5.0
41
- max_temp: float = 30.0
48
+ min_temp: float = MODE_TEMP_MIN
49
+ max_temp: float = MODE_TEMP_MAX
50
+ # Away is the one carousel with its own bounds: it shares the 7 °C floor but
51
+ # stops at 18 °C rather than 30 °C (dimplex-controller-py#98). Consumers
52
+ # should gate the Away picker on these rather than on min_temp/max_temp.
53
+ away_min_temp: float = AWAY_TEMP_MIN
54
+ away_max_temp: float = AWAY_TEMP_MAX
55
+ frost_temp: float = FROST_TEMPERATURE
42
56
  default_boost_minutes: int = DEFAULT_BOOST_MINUTES
43
57
  boost_durations: tuple[int, ...] = DEFAULT_BOOST_DURATIONS
44
58
 
@@ -58,18 +72,25 @@ class ApplianceCapabilities:
58
72
  return {
59
73
  "boost": self.boost,
60
74
  "away": self.away,
75
+ "advance": self.advance,
61
76
  "open_window": self.open_window,
62
77
  "eco_start": self.eco_start,
63
78
  "setback_read": self.setback_read,
64
79
  "setback_write": self.setback_write,
65
80
  "frost": self.frost,
66
81
  "timer": self.timer,
82
+ "setpoint_write": self.setpoint_write,
67
83
  "energy_meter": self.energy_meter,
68
84
  "storage": self.storage,
69
85
  "hot_water": self.hot_water,
86
+ "heat_pump": self.heat_pump,
87
+ "hygiene": self.hygiene,
70
88
  "climate": self.climate,
71
89
  "min_temp": self.min_temp,
72
90
  "max_temp": self.max_temp,
91
+ "away_min_temp": self.away_min_temp,
92
+ "away_max_temp": self.away_max_temp,
93
+ "frost_temp": self.frost_temp,
73
94
  "default_boost_minutes": self.default_boost_minutes,
74
95
  "boost_durations": list(self.boost_durations),
75
96
  "climate_presets": self.climate_presets(),
@@ -125,6 +146,7 @@ def capabilities_for(
125
146
  storage = False
126
147
  energy_meter = False
127
148
  hot_water = False
149
+ heat_pump = False
128
150
  if prov is not None:
129
151
  if prov.charge_capacity is not None and prov.charge_capacity > 0:
130
152
  storage = True
@@ -134,11 +156,15 @@ def capabilities_for(
134
156
  if any(k in tokens for k in ("quantum", "storage", "qrad", "charge")):
135
157
  storage = True
136
158
  energy_meter = True
137
- if any(k in tokens for k in ("hot water", "hotwater", "cylinder", "dhw")):
159
+ if any(k in tokens for k in ("hot water", "hotwater", "cylinder", "dhw", "waterheater")):
160
+ hot_water = True
161
+ if any(k in tokens for k in ("ashw", "heat pump", "heatpump")):
162
+ heat_pump = True
138
163
  hot_water = True
139
164
 
140
165
  boost = True
141
166
  away = True
167
+ advance = True
142
168
  open_window = True
143
169
  eco_start = True
144
170
  setback_read = True
@@ -162,17 +188,26 @@ def capabilities_for(
162
188
  if status.RoomTemperature is not None or status.ActiveSetPointTemperature is not None:
163
189
  climate = True
164
190
 
191
+ # Advance only means something for a scheduled room heater; a cylinder has
192
+ # no "next comfort period" to jump to.
193
+ if hot_water and not climate:
194
+ advance = False
195
+
165
196
  return ApplianceCapabilities(
166
197
  boost=boost,
167
198
  away=away,
199
+ advance=advance,
168
200
  open_window=open_window,
169
201
  eco_start=eco_start,
170
202
  setback_read=setback_read,
171
- setback_write=False,
203
+ setback_write=True,
172
204
  frost=frost,
173
205
  timer=timer,
206
+ setpoint_write=True,
174
207
  energy_meter=energy_meter,
175
208
  storage=storage,
176
209
  hot_water=hot_water,
210
+ heat_pump=heat_pump,
211
+ hygiene=hot_water,
177
212
  climate=climate,
178
213
  )
@@ -18,6 +18,7 @@ import contextlib
18
18
  import json
19
19
  import os
20
20
  import sys
21
+ from collections.abc import Awaitable, Callable
21
22
  from pathlib import Path
22
23
  from typing import Any
23
24
 
@@ -25,7 +26,11 @@ import aiohttp
25
26
 
26
27
  from .auth import TokenBundle
27
28
  from .client import DimplexControl
29
+ from .const import DEFAULT_AWAY_TEMPERATURE, DEFAULT_BOOST_TEMPERATURE
28
30
  from .exceptions import DimplexError
31
+ from .models import SetbackStatus
32
+
33
+ _CoroFactory = Callable[[DimplexControl], Awaitable[int]]
29
34
 
30
35
 
31
36
  def _load_tokens(path: Path | None) -> TokenBundle:
@@ -57,7 +62,7 @@ def _redact(value: str | None, *, show: bool) -> str:
57
62
 
58
63
  async def _with_client(
59
64
  args: argparse.Namespace,
60
- coro_factory: Any,
65
+ coro_factory: _CoroFactory,
61
66
  ) -> int:
62
67
  tokens = _load_tokens(Path(args.tokens_file) if args.tokens_file else None)
63
68
  if not tokens.refresh_token and not tokens.access_token:
@@ -139,7 +144,11 @@ async def cmd_status(client: DimplexControl, args: argparse.Namespace) -> int:
139
144
  print("(no status — appliance offline or empty overview)")
140
145
  return 0
141
146
  status = overview[0]
142
- print(json.dumps(status.model_dump(mode="json"), indent=2, default=str))
147
+ payload = status.model_dump(mode="json")
148
+ # Decoded views the raw payload does not give you.
149
+ payload["_active_modes"] = status.active_modes
150
+ payload["_active_setpoint_temperature"] = status.active_setpoint_temperature
151
+ print(json.dumps(payload, indent=2, default=str))
143
152
  return 0
144
153
 
145
154
 
@@ -177,6 +186,8 @@ async def cmd_away(client: DimplexControl, args: argparse.Namespace) -> int:
177
186
  [args.appliance],
178
187
  temperature=args.temperature,
179
188
  enable=not args.clear,
189
+ until=args.until,
190
+ number_of_days=args.days,
180
191
  )
181
192
  print("ok")
182
193
  return 0
@@ -191,6 +202,54 @@ async def cmd_eco(client: DimplexControl, args: argparse.Namespace) -> int:
191
202
  return 0
192
203
 
193
204
 
205
+ async def cmd_setpoint(client: DimplexControl, args: argparse.Namespace) -> int:
206
+ """Set the active setpoint via the dedicated (non-destructive) endpoint."""
207
+ if not args.yes:
208
+ print("error: control commands require --yes", file=sys.stderr)
209
+ return 2
210
+ await client.set_appliance_setpoint_temperature(args.hub, [args.appliance], args.temperature)
211
+ print("ok")
212
+ return 0
213
+
214
+
215
+ async def cmd_off(client: DimplexControl, args: argparse.Namespace) -> int:
216
+ """Turn the appliance off the way the app does — frost protection at 7 °C."""
217
+ if not args.yes:
218
+ print("error: control commands require --yes", file=sys.stderr)
219
+ return 2
220
+ await client.set_frost_protect(args.hub, [args.appliance], enable=not args.clear)
221
+ print("ok")
222
+ return 0
223
+
224
+
225
+ async def cmd_advance(client: DimplexControl, args: argparse.Namespace) -> int:
226
+ if not args.yes:
227
+ print("error: control commands require --yes", file=sys.stderr)
228
+ return 2
229
+ await client.set_advance(
230
+ args.hub,
231
+ [args.appliance],
232
+ enable=not args.clear,
233
+ temperature=args.temperature,
234
+ )
235
+ print("ok")
236
+ return 0
237
+
238
+
239
+ async def cmd_setback(client: DimplexControl, args: argparse.Namespace) -> int:
240
+ if not args.yes:
241
+ print("error: control commands require --yes", file=sys.stderr)
242
+ return 2
243
+ await client.set_setback_temperature(
244
+ args.hub,
245
+ [args.appliance],
246
+ temperature=args.temperature,
247
+ status=SetbackStatus.INACTIVE if args.clear else SetbackStatus.ACTIVE,
248
+ )
249
+ print("ok")
250
+ return 0
251
+
252
+
194
253
  def build_parser() -> argparse.ArgumentParser:
195
254
  parser = argparse.ArgumentParser(
196
255
  prog="dimplex",
@@ -233,21 +292,54 @@ def build_parser() -> argparse.ArgumentParser:
233
292
  p_energy.add_argument("--days", type=int, default=30)
234
293
  p_energy.set_defaults(func=cmd_energy)
235
294
 
236
- for name, help_text, defaults in (
237
- ("boost", "Enable or clear boost (--yes required)", {"minutes": 60, "temperature": 25.0}),
238
- ("away", "Enable or clear away (--yes required)", {"temperature": 16.0}),
295
+ _CONTROL_COMMANDS: dict[str, Callable[..., Awaitable[int]]] = {
296
+ "boost": cmd_boost,
297
+ "away": cmd_away,
298
+ "eco": cmd_eco,
299
+ "setpoint": cmd_setpoint,
300
+ "off": cmd_off,
301
+ "advance": cmd_advance,
302
+ "setback": cmd_setback,
303
+ }
304
+
305
+ for name, help_text, opts in (
306
+ (
307
+ "boost",
308
+ "Enable or clear timed Boost (--yes required)",
309
+ {"temperature": DEFAULT_BOOST_TEMPERATURE, "minutes": 60},
310
+ ),
311
+ (
312
+ "away",
313
+ "Enable or clear Away setback, 7-30 °C (--yes required)",
314
+ {"temperature": DEFAULT_AWAY_TEMPERATURE, "away_until": True},
315
+ ),
239
316
  ("eco", "Enable or clear EcoStart (--yes required)", {}),
317
+ (
318
+ "setpoint",
319
+ "Set the active setpoint, non-destructive (--yes required)",
320
+ {"temperature": 21.0},
321
+ ),
322
+ ("off", "Turn off via frost protection at 7 °C (--yes required)", {}),
323
+ ("advance", "Advance to the next schedule period (--yes required)", {"temperature": None}),
324
+ (
325
+ "setback",
326
+ "Write the setback temperature (--yes required; untested)",
327
+ {"temperature": 16.0},
328
+ ),
240
329
  ):
241
330
  p = sub.add_parser(name, help=help_text)
242
331
  p.add_argument("hub")
243
332
  p.add_argument("appliance")
244
333
  p.add_argument("--yes", action="store_true", help="Confirm control write")
245
334
  p.add_argument("--clear", action="store_true", help="Disable the mode")
246
- if "temperature" in defaults:
247
- p.add_argument("--temperature", type=float, default=defaults["temperature"])
248
- if "minutes" in defaults:
249
- p.add_argument("--minutes", type=int, default=defaults["minutes"])
250
- p.set_defaults(func={"boost": cmd_boost, "away": cmd_away, "eco": cmd_eco}[name])
335
+ if "temperature" in opts:
336
+ p.add_argument("--temperature", type=float, default=opts["temperature"])
337
+ if "minutes" in opts:
338
+ p.add_argument("--minutes", type=int, default=opts["minutes"])
339
+ if opts.get("away_until"):
340
+ p.add_argument("--days", type=int, default=0, help="Away duration in days (converted to a date)")
341
+ p.add_argument("--until", help="Away-until datetime, ISO-8601 (overrides --days)")
342
+ p.set_defaults(func=_CONTROL_COMMANDS[name])
251
343
 
252
344
  return parser
253
345
 
@@ -257,7 +349,8 @@ def main(argv: list[str] | None = None) -> int:
257
349
  args = parser.parse_args(argv)
258
350
 
259
351
  async def run(client: DimplexControl) -> int:
260
- return await args.func(client, args)
352
+ func: Callable[..., Awaitable[int]] = args.func
353
+ return await func(client, args)
261
354
 
262
355
  return asyncio.run(_with_client(args, run))
263
356