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.
- {dimplex_controller-0.12.0 → dimplex_controller-0.13.1}/PKG-INFO +59 -18
- {dimplex_controller-0.12.0 → dimplex_controller-0.13.1}/README.md +57 -16
- {dimplex_controller-0.12.0 → dimplex_controller-0.13.1}/dimplex_controller/__init__.py +33 -2
- {dimplex_controller-0.12.0 → dimplex_controller-0.13.1}/dimplex_controller/auth.py +15 -1
- {dimplex_controller-0.12.0 → dimplex_controller-0.13.1}/dimplex_controller/capabilities.py +41 -6
- {dimplex_controller-0.12.0 → dimplex_controller-0.13.1}/dimplex_controller/cli.py +104 -11
- {dimplex_controller-0.12.0 → dimplex_controller-0.13.1}/dimplex_controller/client.py +440 -29
- dimplex_controller-0.13.1/dimplex_controller/const.py +58 -0
- {dimplex_controller-0.12.0 → dimplex_controller-0.13.1}/dimplex_controller/models.py +183 -28
- {dimplex_controller-0.12.0 → dimplex_controller-0.13.1}/dimplex_controller/telemetry.py +1 -1
- {dimplex_controller-0.12.0 → dimplex_controller-0.13.1}/pyproject.toml +14 -8
- dimplex_controller-0.12.0/dimplex_controller/const.py +0 -22
- {dimplex_controller-0.12.0 → dimplex_controller-0.13.1}/LICENSE +0 -0
- {dimplex_controller-0.12.0 → dimplex_controller-0.13.1}/dimplex_controller/exceptions.py +0 -0
- {dimplex_controller-0.12.0 → dimplex_controller-0.13.1}/dimplex_controller/py.typed +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dimplex-controller
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Python client for Dimplex
|
|
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
|
-
[](https://pypi.org/project/dimplex-controller/)
|
|
31
|
+
[](https://www.python.org/downloads/)
|
|
32
32
|
[](LICENSE)
|
|
33
|
-
[](https://github.com/KRoperUK/dimplex-controller-py/actions/workflows/test.yml)
|
|
34
|
+
[](https://pypi.org/project/dimplex-controller/)
|
|
35
|
+
[](https://buymeacoffee.com/kroperukc)
|
|
36
36
|
|
|
37
37
|
<p align="center">
|
|
38
38
|
<strong>Async Python client for controlling Glen Dimplex Heating & 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
|
-
#
|
|
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
|
-
#
|
|
192
|
-
|
|
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)` |
|
|
237
|
-
| `set_target_temperature(hub_id, appliance_id, temp)` |
|
|
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
|
-
| `
|
|
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)` |
|
|
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
|
-
[](https://pypi.org/project/dimplex-controller/)
|
|
4
|
+
[](https://www.python.org/downloads/)
|
|
5
5
|
[](LICENSE)
|
|
6
|
-
[](https://github.com/KRoperUK/dimplex-controller-py/actions/workflows/test.yml)
|
|
7
|
+
[](https://pypi.org/project/dimplex-controller/)
|
|
8
|
+
[](https://buymeacoffee.com/kroperukc)
|
|
9
9
|
|
|
10
10
|
<p align="center">
|
|
11
11
|
<strong>Async Python client for controlling Glen Dimplex Heating & 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
|
-
#
|
|
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
|
-
#
|
|
165
|
-
|
|
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)` |
|
|
210
|
-
| `set_target_temperature(hub_id, appliance_id, temp)` |
|
|
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
|
-
| `
|
|
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)` |
|
|
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
|
-
|
|
4
|
-
API.
|
|
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(
|
|
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 =
|
|
34
|
-
frost: bool = True #
|
|
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 =
|
|
41
|
-
max_temp: float =
|
|
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=
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
247
|
-
p.add_argument("--temperature", type=float, default=
|
|
248
|
-
if "minutes" in
|
|
249
|
-
p.add_argument("--minutes", type=int, default=
|
|
250
|
-
|
|
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
|
-
|
|
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
|
|