dimplex-controller 0.14.0__tar.gz → 0.15.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.14.0 → dimplex_controller-0.15.1}/PKG-INFO +6 -4
- {dimplex_controller-0.14.0 → dimplex_controller-0.15.1}/README.md +3 -1
- {dimplex_controller-0.14.0 → dimplex_controller-0.15.1}/dimplex_controller/__init__.py +6 -0
- {dimplex_controller-0.14.0 → dimplex_controller-0.15.1}/dimplex_controller/auth.py +20 -3
- {dimplex_controller-0.14.0 → dimplex_controller-0.15.1}/dimplex_controller/capabilities.py +27 -13
- {dimplex_controller-0.14.0 → dimplex_controller-0.15.1}/dimplex_controller/client.py +97 -12
- {dimplex_controller-0.14.0 → dimplex_controller-0.15.1}/dimplex_controller/models.py +81 -0
- {dimplex_controller-0.14.0 → dimplex_controller-0.15.1}/dimplex_controller/telemetry.py +28 -0
- {dimplex_controller-0.14.0 → dimplex_controller-0.15.1}/pyproject.toml +3 -3
- {dimplex_controller-0.14.0 → dimplex_controller-0.15.1}/pyproject.toml.orig +15 -6
- {dimplex_controller-0.14.0 → dimplex_controller-0.15.1}/LICENSE +0 -0
- {dimplex_controller-0.14.0 → dimplex_controller-0.15.1}/dimplex_controller/cli.py +0 -0
- {dimplex_controller-0.14.0 → dimplex_controller-0.15.1}/dimplex_controller/const.py +0 -0
- {dimplex_controller-0.14.0 → dimplex_controller-0.15.1}/dimplex_controller/exceptions.py +0 -0
- {dimplex_controller-0.14.0 → dimplex_controller-0.15.1}/dimplex_controller/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dimplex-controller
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.15.1
|
|
4
4
|
Summary: Unofficial async Python client for Dimplex (GDHV IoT) heating appliances — not affiliated with Glen Dimplex
|
|
5
5
|
Keywords: dimplex,heating,control,iot,asyncio
|
|
6
6
|
Author: Kieran Roper
|
|
@@ -17,8 +17,8 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.14
|
|
19
19
|
Classifier: Topic :: Home Automation
|
|
20
|
-
Requires-Dist: aiohttp>=3.
|
|
21
|
-
Requires-Dist: pydantic>=2.
|
|
20
|
+
Requires-Dist: aiohttp>=3.9.0,<4
|
|
21
|
+
Requires-Dist: pydantic>=2.0.0,<3
|
|
22
22
|
Requires-Python: >=3.10, <4.0
|
|
23
23
|
Project-URL: Homepage, https://github.com/KRoperUK/dimplex-controller-py
|
|
24
24
|
Project-URL: Repository, https://github.com/KRoperUK/dimplex-controller-py
|
|
@@ -285,7 +285,9 @@ Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_toke
|
|
|
285
285
|
| `set_setback_temperature(...)` | Write the setback temperature (untested). |
|
|
286
286
|
| `set_eco_start(hub_id, appliance_ids, enable)` | Toggle EcoStart. |
|
|
287
287
|
| `set_open_window_detection(hub_id, appliance_ids, enable)` | Toggle Open Window Detection. |
|
|
288
|
-
| `
|
|
288
|
+
| `set_hot_water_holiday(hub_id, appliance_ids, until, *, enable, heat_pump)` | Cylinder Holiday on/off (validated on an ASHW cylinder). |
|
|
289
|
+
| `get_heat_pump_hot_water_schedule(...)` | ASHW cylinder schedule as `HeatPumpHwcSchedule` (validated). Writing it answers 403. |
|
|
290
|
+
| `set_hot_water_*(...)` | Remaining cylinder controls (untested — no hardware). |
|
|
289
291
|
| `get_tsi_energy_report(hub_id, ...)` | Returns `TsiEnergyReport`. |
|
|
290
292
|
| `capabilities_for(appliance, *, status, product)` | Derive an `ApplianceCapabilities` matrix. |
|
|
291
293
|
| `export_tokens()` / `apply_tokens(bundle)` | Token persistence helpers. |
|
|
@@ -259,7 +259,9 @@ Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_toke
|
|
|
259
259
|
| `set_setback_temperature(...)` | Write the setback temperature (untested). |
|
|
260
260
|
| `set_eco_start(hub_id, appliance_ids, enable)` | Toggle EcoStart. |
|
|
261
261
|
| `set_open_window_detection(hub_id, appliance_ids, enable)` | Toggle Open Window Detection. |
|
|
262
|
-
| `
|
|
262
|
+
| `set_hot_water_holiday(hub_id, appliance_ids, until, *, enable, heat_pump)` | Cylinder Holiday on/off (validated on an ASHW cylinder). |
|
|
263
|
+
| `get_heat_pump_hot_water_schedule(...)` | ASHW cylinder schedule as `HeatPumpHwcSchedule` (validated). Writing it answers 403. |
|
|
264
|
+
| `set_hot_water_*(...)` | Remaining cylinder controls (untested — no hardware). |
|
|
263
265
|
| `get_tsi_energy_report(hub_id, ...)` | Returns `TsiEnergyReport`. |
|
|
264
266
|
| `capabilities_for(appliance, *, status, product)` | Derive an `ApplianceCapabilities` matrix. |
|
|
265
267
|
| `export_tokens()` / `apply_tokens(bundle)` | Token persistence helpers. |
|
|
@@ -45,6 +45,8 @@ from .models import (
|
|
|
45
45
|
ApplianceModeStatus,
|
|
46
46
|
ApplianceStatus,
|
|
47
47
|
AutomaticProvisioning,
|
|
48
|
+
HeatPumpHwcPeriod,
|
|
49
|
+
HeatPumpHwcSchedule,
|
|
48
50
|
Hub,
|
|
49
51
|
HygieneFrequency,
|
|
50
52
|
ProductModel,
|
|
@@ -62,6 +64,7 @@ from .telemetry import (
|
|
|
62
64
|
EnergySummary,
|
|
63
65
|
filter_telemetry_points,
|
|
64
66
|
parse_telemetry_points,
|
|
67
|
+
reported_energy,
|
|
65
68
|
summarise_energy,
|
|
66
69
|
)
|
|
67
70
|
|
|
@@ -83,6 +86,8 @@ __all__ = [
|
|
|
83
86
|
"SetbackStatus",
|
|
84
87
|
"TimerMode",
|
|
85
88
|
"TimerModeSettings",
|
|
89
|
+
"HeatPumpHwcPeriod",
|
|
90
|
+
"HeatPumpHwcSchedule",
|
|
86
91
|
"TimerPeriod",
|
|
87
92
|
"AutomaticProvisioning",
|
|
88
93
|
"ProductModel",
|
|
@@ -98,6 +103,7 @@ __all__ = [
|
|
|
98
103
|
"NULL_DATETIME",
|
|
99
104
|
"parse_telemetry_points",
|
|
100
105
|
"filter_telemetry_points",
|
|
106
|
+
"reported_energy",
|
|
101
107
|
"summarise_energy",
|
|
102
108
|
"EnergySummary",
|
|
103
109
|
"VALUE_KEY_T1",
|
|
@@ -101,6 +101,10 @@ class AuthManager:
|
|
|
101
101
|
self._access_token: str | None = bundle.access_token
|
|
102
102
|
self._refresh_token: str | None = bundle.refresh_token
|
|
103
103
|
self._expires_at: float = bundle.expires_at
|
|
104
|
+
# Serialises refreshes so concurrent callers share one POST (see
|
|
105
|
+
# get_access_token). Created eagerly: asyncio locks bind to the running
|
|
106
|
+
# loop on first use, not on construction.
|
|
107
|
+
self._refresh_lock = asyncio.Lock()
|
|
104
108
|
|
|
105
109
|
def _request_kwargs(self) -> dict[str, Any]:
|
|
106
110
|
"""Common per-request kwargs (applies the configured timeout)."""
|
|
@@ -130,7 +134,17 @@ class AuthManager:
|
|
|
130
134
|
self._expires_at = bundle.expires_at
|
|
131
135
|
|
|
132
136
|
async def get_access_token(self) -> str:
|
|
133
|
-
"""Get a valid access token, refreshing if necessary.
|
|
137
|
+
"""Get a valid access token, refreshing if necessary.
|
|
138
|
+
|
|
139
|
+
Refresh is single-flight. Callers that see an expired token queue on
|
|
140
|
+
:attr:`_refresh_lock` and re-check expiry once they hold it, so only the
|
|
141
|
+
first performs the ``POST /token`` and the rest reuse its result. The
|
|
142
|
+
cloud rotates the refresh token on every refresh, so two concurrent
|
|
143
|
+
refreshes sent the same token meant all but one were answered
|
|
144
|
+
``invalid_grant`` — a permanent auth failure produced by ordinary
|
|
145
|
+
concurrency (dimplex-controller-py#139). A failed refresh raises through
|
|
146
|
+
``async with``, leaving the lock free for the next caller.
|
|
147
|
+
"""
|
|
134
148
|
if not self._refresh_token:
|
|
135
149
|
raise DimplexAuthInvalidGrantError(
|
|
136
150
|
"No refresh token available. User must authenticate first.",
|
|
@@ -141,8 +155,11 @@ class AuthManager:
|
|
|
141
155
|
assert self._access_token is not None
|
|
142
156
|
return self._access_token
|
|
143
157
|
|
|
144
|
-
|
|
145
|
-
|
|
158
|
+
async with self._refresh_lock:
|
|
159
|
+
# Another caller may have refreshed while we waited for the lock.
|
|
160
|
+
if not self.is_authenticated:
|
|
161
|
+
await self.refresh_tokens()
|
|
162
|
+
|
|
146
163
|
if not self._access_token:
|
|
147
164
|
raise DimplexAuthInvalidGrantError(
|
|
148
165
|
"Token refresh succeeded but no access token was returned.",
|
|
@@ -24,6 +24,14 @@ class ApplianceCapabilities:
|
|
|
24
24
|
Flags are best-effort: when status/product metadata is incomplete the
|
|
25
25
|
library prefers enabling known cloud control paths (boost/away/OWD) so
|
|
26
26
|
users are not locked out of working RPCs.
|
|
27
|
+
|
|
28
|
+
One flag is weaker than the rest and must not be used as a gate:
|
|
29
|
+
:attr:`energy_meter` is a *family hint* derived from the nameplate, so it is
|
|
30
|
+
true for essentially every appliance with a rated power. When the question is
|
|
31
|
+
"has this appliance ever reported energy?", ask the telemetry —
|
|
32
|
+
:func:`dimplex_controller.reported_energy`, or
|
|
33
|
+
:func:`dimplex_controller.summarise_energy` when the totals are wanted too
|
|
34
|
+
(dimplex-controller-py#113).
|
|
27
35
|
"""
|
|
28
36
|
|
|
29
37
|
boost: bool = True
|
|
@@ -39,6 +47,7 @@ class ApplianceCapabilities:
|
|
|
39
47
|
# setpoint path. Preferred over rewriting the schedule via SetTimerMode,
|
|
40
48
|
# which Quantum rejects with HTTP 403.
|
|
41
49
|
setpoint_write: bool = True
|
|
50
|
+
# Family hint, not proof of metering — see the class docstring before gating on it.
|
|
42
51
|
energy_meter: bool = False
|
|
43
52
|
storage: bool = False
|
|
44
53
|
hot_water: bool = False
|
|
@@ -162,15 +171,23 @@ def capabilities_for(
|
|
|
162
171
|
heat_pump = True
|
|
163
172
|
hot_water = True
|
|
164
173
|
|
|
165
|
-
|
|
166
|
-
|
|
174
|
+
if status is not None and status.AvailableHotWater is not None:
|
|
175
|
+
hot_water = True
|
|
176
|
+
|
|
177
|
+
# A cylinder reports its *water* target in ActiveSetPointTemperature (45 °C
|
|
178
|
+
# on a live ASHW cylinder), so only a room temperature makes it room climate.
|
|
179
|
+
# Without one, the room-heater features default to off — a live cylinder
|
|
180
|
+
# sends null for every one of them — unless its status reports them.
|
|
181
|
+
climate = not hot_water or (status is not None and status.RoomTemperature is not None)
|
|
182
|
+
|
|
183
|
+
boost = climate
|
|
184
|
+
away = climate
|
|
167
185
|
advance = True
|
|
168
|
-
open_window =
|
|
169
|
-
eco_start =
|
|
170
|
-
setback_read =
|
|
171
|
-
frost =
|
|
186
|
+
open_window = climate
|
|
187
|
+
eco_start = climate
|
|
188
|
+
setback_read = climate
|
|
189
|
+
frost = climate
|
|
172
190
|
timer = True
|
|
173
|
-
climate = not hot_water # cylinder-only appliances are not room climate
|
|
174
191
|
|
|
175
192
|
if status is not None:
|
|
176
193
|
if status.BoostDuration is not None or status.BoostTemperature is not None:
|
|
@@ -183,10 +200,6 @@ def capabilities_for(
|
|
|
183
200
|
eco_start = True
|
|
184
201
|
if status.SetbackEnabled is not None or status.SetbackTemperature is not None:
|
|
185
202
|
setback_read = True
|
|
186
|
-
if status.AvailableHotWater is not None:
|
|
187
|
-
hot_water = True
|
|
188
|
-
if status.RoomTemperature is not None or status.ActiveSetPointTemperature is not None:
|
|
189
|
-
climate = True
|
|
190
203
|
|
|
191
204
|
# Advance only means something for a scheduled room heater; a cylinder has
|
|
192
205
|
# no "next comfort period" to jump to.
|
|
@@ -200,10 +213,11 @@ def capabilities_for(
|
|
|
200
213
|
open_window=open_window,
|
|
201
214
|
eco_start=eco_start,
|
|
202
215
|
setback_read=setback_read,
|
|
203
|
-
setback_write=
|
|
216
|
+
setback_write=climate,
|
|
204
217
|
frost=frost,
|
|
205
218
|
timer=timer,
|
|
206
|
-
|
|
219
|
+
# The room setpoint endpoint; cylinders have their own (Set*TemperatureHwc).
|
|
220
|
+
setpoint_write=climate,
|
|
207
221
|
energy_meter=energy_meter,
|
|
208
222
|
storage=storage,
|
|
209
223
|
hot_water=hot_water,
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import json
|
|
5
5
|
import logging
|
|
6
|
+
import math
|
|
6
7
|
import random
|
|
7
8
|
from datetime import datetime, timedelta, timezone
|
|
8
9
|
from typing import Any
|
|
@@ -25,7 +26,8 @@ from .const import (
|
|
|
25
26
|
HEADER_DEVICE_OS,
|
|
26
27
|
HEADER_DEVICE_VERSION,
|
|
27
28
|
HEADER_USER_AGENT,
|
|
28
|
-
|
|
29
|
+
MODE_TEMP_MAX,
|
|
30
|
+
MODE_TEMP_MIN,
|
|
29
31
|
NO_SETPOINT_SENTINEL,
|
|
30
32
|
NULL_DATETIME,
|
|
31
33
|
)
|
|
@@ -36,6 +38,7 @@ from .models import (
|
|
|
36
38
|
ApplianceModeSettings,
|
|
37
39
|
ApplianceModeStatus,
|
|
38
40
|
ApplianceStatus,
|
|
41
|
+
HeatPumpHwcSchedule,
|
|
39
42
|
Hub,
|
|
40
43
|
HygieneFrequency,
|
|
41
44
|
ProductModel,
|
|
@@ -92,6 +95,28 @@ def _clamp_away_temperature(temperature: float) -> float:
|
|
|
92
95
|
return clamped
|
|
93
96
|
|
|
94
97
|
|
|
98
|
+
def _clamp_mode_temperature(temperature: float) -> float:
|
|
99
|
+
"""Clamp a setpoint into the 7–30 °C the app's mode carousels offer.
|
|
100
|
+
|
|
101
|
+
The wire field is a byte, so an out-of-range value used to be sent verbatim
|
|
102
|
+
and only reduced (or ignored) by the cloud — the write reported success for
|
|
103
|
+
a setpoint the appliance never reached. Clamping, as Away already does,
|
|
104
|
+
keeps the requested value and the applied value in agreement and makes the
|
|
105
|
+
adjustment visible (dimplex-controller-py#141).
|
|
106
|
+
"""
|
|
107
|
+
value = float(temperature)
|
|
108
|
+
clamped = min(max(value, MODE_TEMP_MIN), MODE_TEMP_MAX)
|
|
109
|
+
if clamped != value:
|
|
110
|
+
_LOGGER.warning(
|
|
111
|
+
"Setpoint %.1f °C is outside the %s–%s °C the app offers; using %.1f °C",
|
|
112
|
+
value,
|
|
113
|
+
MODE_TEMP_MIN,
|
|
114
|
+
MODE_TEMP_MAX,
|
|
115
|
+
clamped,
|
|
116
|
+
)
|
|
117
|
+
return clamped
|
|
118
|
+
|
|
119
|
+
|
|
95
120
|
def _coerce_timeout(timeout: float | aiohttp.ClientTimeout | None) -> aiohttp.ClientTimeout | None:
|
|
96
121
|
"""Normalise a timeout value into an :class:`aiohttp.ClientTimeout`.
|
|
97
122
|
|
|
@@ -258,7 +283,11 @@ class DimplexControl:
|
|
|
258
283
|
for attempt in range(attempts):
|
|
259
284
|
try:
|
|
260
285
|
async with self._session.request(method, url, headers=headers, **kwargs) as resp:
|
|
261
|
-
|
|
286
|
+
# Any 2xx is success. Control endpoints may answer 204 No
|
|
287
|
+
# Content, and an empty body is already what
|
|
288
|
+
# _decode_ok_body handles; gating on exactly 200 raised a
|
|
289
|
+
# successful write as an API error (dimplex-controller-py#141).
|
|
290
|
+
if 200 <= resp.status < 300:
|
|
262
291
|
return await self._decode_ok_body(resp)
|
|
263
292
|
|
|
264
293
|
text = await resp.text()
|
|
@@ -582,18 +611,27 @@ class DimplexControl:
|
|
|
582
611
|
with ``Status=1`` (on) / ``Status=0`` (off); ``Time`` carries the boost
|
|
583
612
|
duration in minutes and ``Temperature`` the boost target (7–30 °C).
|
|
584
613
|
|
|
614
|
+
A non-positive ``duration_minutes`` raises :class:`ValueError` when
|
|
615
|
+
engaging: the cloud accepted ``Time=0`` and the appliance simply did not
|
|
616
|
+
boost, so the caller was told a boost had started that never would
|
|
617
|
+
(dimplex-controller-py#141). Clearing (``enable=False``) is unaffected —
|
|
618
|
+
it sends ``Time=0`` by design.
|
|
619
|
+
|
|
585
620
|
.. versionchanged:: 0.13.0
|
|
586
621
|
Previously sent ``ApplianceModes=16``, which is **Advance** — the
|
|
587
622
|
appliance advanced its schedule instead of boosting, and the
|
|
588
623
|
requested duration was discarded.
|
|
589
624
|
"""
|
|
625
|
+
minutes = int(duration_minutes)
|
|
626
|
+
if enable and minutes <= 0:
|
|
627
|
+
raise ValueError(f"boost duration must be a positive number of minutes, got {duration_minutes!r}")
|
|
590
628
|
await self.set_mode_flag(
|
|
591
629
|
hub_id,
|
|
592
630
|
appliance_ids,
|
|
593
631
|
ApplianceModeFlag.BOOST,
|
|
594
632
|
enable=enable,
|
|
595
633
|
temperature=temperature,
|
|
596
|
-
minutes=
|
|
634
|
+
minutes=minutes if enable else 0,
|
|
597
635
|
)
|
|
598
636
|
|
|
599
637
|
async def clear_boost(
|
|
@@ -761,12 +799,16 @@ class DimplexControl:
|
|
|
761
799
|
the schedule and is rejected with HTTP 403 on Quantum.
|
|
762
800
|
|
|
763
801
|
``Temperature`` is a wire ``byte``, so the value is rounded to whole
|
|
764
|
-
degrees
|
|
802
|
+
degrees, and it is clamped into the 7–30 °C range the app offers
|
|
803
|
+
(:data:`~dimplex_controller.MODE_TEMP_MIN` /
|
|
804
|
+
:data:`~dimplex_controller.MODE_TEMP_MAX`) with a warning when it is
|
|
805
|
+
outside — the cloud silently reduces such a value
|
|
806
|
+
(dimplex-controller-py#141).
|
|
765
807
|
"""
|
|
766
808
|
payload = {
|
|
767
809
|
"HubId": hub_id,
|
|
768
810
|
"ApplianceIds": appliance_ids,
|
|
769
|
-
"Temperature": int(round(temperature)),
|
|
811
|
+
"Temperature": int(round(_clamp_mode_temperature(temperature))),
|
|
770
812
|
}
|
|
771
813
|
await self._request("POST", "/RemoteControl/SetApplianceSetpointTemperature", json=payload)
|
|
772
814
|
|
|
@@ -783,7 +825,8 @@ class DimplexControl:
|
|
|
783
825
|
``status`` is an :class:`~dimplex_controller.SetbackStatus` byte —
|
|
784
826
|
``ACTIVE``/``INACTIVE`` for a plain setback, with ``DSM_MODE`` and
|
|
785
827
|
``LOCAL_FREQUENCY_CONTROL_ACTIVE`` reserved for demand-side-management
|
|
786
|
-
use.
|
|
828
|
+
use. ``temperature`` is rounded and clamped into 7–30 °C, as
|
|
829
|
+
:meth:`set_appliance_setpoint_temperature` does.
|
|
787
830
|
|
|
788
831
|
Confirmed present in APK 2.26.0 but **not yet validated on live
|
|
789
832
|
hardware**.
|
|
@@ -792,7 +835,7 @@ class DimplexControl:
|
|
|
792
835
|
"HubId": hub_id,
|
|
793
836
|
"ApplianceIds": appliance_ids,
|
|
794
837
|
"Status": int(status),
|
|
795
|
-
"Temperature": int(round(temperature)),
|
|
838
|
+
"Temperature": int(round(_clamp_mode_temperature(temperature))),
|
|
796
839
|
}
|
|
797
840
|
await self._request("POST", "/RemoteControl/SetSetbackTemperature", json=payload)
|
|
798
841
|
|
|
@@ -840,6 +883,42 @@ class DimplexControl:
|
|
|
840
883
|
endpoint=endpoint,
|
|
841
884
|
)
|
|
842
885
|
|
|
886
|
+
async def set_hot_water_holiday(
|
|
887
|
+
self,
|
|
888
|
+
hub_id: str,
|
|
889
|
+
appliance_ids: list[str],
|
|
890
|
+
until: datetime | None = None,
|
|
891
|
+
*,
|
|
892
|
+
enable: bool = True,
|
|
893
|
+
heat_pump: bool = False,
|
|
894
|
+
) -> None:
|
|
895
|
+
"""Put a cylinder on Holiday (standby) until ``until``, or clear it.
|
|
896
|
+
|
|
897
|
+
Verified on a live ASHW cylinder (EDL200UK-630RF) with ``heat_pump=True``:
|
|
898
|
+
engaging moved ``ApplianceModes`` 8192 → 8200 and set
|
|
899
|
+
``HolidayNumOfDays``, heating stopped, and clearing restored 8192. The
|
|
900
|
+
cylinder counts holidays in whole days, so ``until`` is also sent as a
|
|
901
|
+
rounded-up day count; a caller that wants a time of day has to clear
|
|
902
|
+
Holiday itself at that time.
|
|
903
|
+
"""
|
|
904
|
+
days = 0
|
|
905
|
+
if enable:
|
|
906
|
+
if until is None:
|
|
907
|
+
raise ValueError("a holiday needs an end time")
|
|
908
|
+
end = until if until.tzinfo is not None else until.replace(tzinfo=timezone.utc)
|
|
909
|
+
seconds = (end - datetime.now(timezone.utc)).total_seconds()
|
|
910
|
+
days = max(1, math.ceil(seconds / 86400))
|
|
911
|
+
endpoint = "/RemoteControl/SetApplianceModeHeatPumpHwc" if heat_pump else "/RemoteControl/SetApplianceModeHwc"
|
|
912
|
+
await self.set_mode_flag(
|
|
913
|
+
hub_id,
|
|
914
|
+
appliance_ids,
|
|
915
|
+
ApplianceModeFlag.HOLIDAY,
|
|
916
|
+
enable=enable,
|
|
917
|
+
until=until,
|
|
918
|
+
number_of_days=days,
|
|
919
|
+
endpoint=endpoint,
|
|
920
|
+
)
|
|
921
|
+
|
|
843
922
|
async def set_hot_water_boost_temperature(
|
|
844
923
|
self, hub_id: str, appliance_ids: list[str], temperature: float, *, enable: bool = True
|
|
845
924
|
) -> None:
|
|
@@ -902,10 +981,12 @@ class DimplexControl:
|
|
|
902
981
|
endpoint=endpoint,
|
|
903
982
|
)
|
|
904
983
|
|
|
905
|
-
async def get_heat_pump_hot_water_schedule(self, hub_id: str, appliance_id: str) ->
|
|
906
|
-
"""Read an ASHW cylinder's schedule.
|
|
984
|
+
async def get_heat_pump_hot_water_schedule(self, hub_id: str, appliance_id: str) -> HeatPumpHwcSchedule:
|
|
985
|
+
"""Read an ASHW cylinder's weekly schedule.
|
|
907
986
|
|
|
908
|
-
|
|
987
|
+
Verified on a live ASHW cylinder (EDL200UK-630RF). The reply does not fit
|
|
988
|
+
:class:`TimerModeSettings` — there is no ``TimerMode`` and each period is
|
|
989
|
+
``{DayOfWeek, StartTime, Mode}`` — hence :class:`HeatPumpHwcSchedule`.
|
|
909
990
|
"""
|
|
910
991
|
payload = {"HubId": hub_id, "ApplianceId": appliance_id}
|
|
911
992
|
data = await self._request(
|
|
@@ -913,12 +994,16 @@ class DimplexControl:
|
|
|
913
994
|
"/RemoteControl/ApiGetTimerModeDetailsForHeatPumpHwcAppliance",
|
|
914
995
|
json=payload,
|
|
915
996
|
)
|
|
916
|
-
return
|
|
997
|
+
return HeatPumpHwcSchedule.model_validate(data)
|
|
917
998
|
|
|
918
999
|
async def set_heat_pump_hot_water_schedule(self, settings: TimerModeSettings) -> TimerModeSettings:
|
|
919
1000
|
"""Write an ASHW cylinder's schedule periods.
|
|
920
1001
|
|
|
921
|
-
.. warning::
|
|
1002
|
+
.. warning:: Does not work yet. On a live ASHW cylinder this endpoint
|
|
1003
|
+
answered HTTP 403 to every body tried — wrapped in ``TimerModeSettings``
|
|
1004
|
+
or ``Settings``, with or without ``TimerMode``, integer or ``HH:MM:SS``
|
|
1005
|
+
times, and app versions up to 3.0.0 — while unwrapped bodies got 400.
|
|
1006
|
+
The request the official app sends has not been captured.
|
|
922
1007
|
"""
|
|
923
1008
|
payload = {"TimerModeSettings": settings.model_dump(mode="json")}
|
|
924
1009
|
await self._request("POST", "/RemoteControl/UpdateHeatPumpHwcSchedulePeriods", json=payload)
|
|
@@ -144,6 +144,35 @@ class ProductModel(BaseModel):
|
|
|
144
144
|
return None
|
|
145
145
|
|
|
146
146
|
|
|
147
|
+
_DEFAULT_DATETIME = datetime(1, 1, 1)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _is_null_datetime(value: str) -> bool:
|
|
151
|
+
"""True when ``value`` is the cloud's ``default(DateTime)`` sentinel."""
|
|
152
|
+
text = value.strip()
|
|
153
|
+
if not text.startswith(NULL_DATETIME):
|
|
154
|
+
return False
|
|
155
|
+
# Tolerate .NET's optional fractional seconds, ``Z`` suffix or offset, e.g.
|
|
156
|
+
# ``0001-01-01T00:00:00Z`` or ``0001-01-01T00:00:00.0000000+00:00``.
|
|
157
|
+
return not text[len(NULL_DATETIME) :].strip(".0Z+:")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _map_null_datetime(value: Any) -> Any:
|
|
161
|
+
"""Return ``None`` for the .NET null datetime, leaving real values alone.
|
|
162
|
+
|
|
163
|
+
The cloud sends ``0001-01-01T00:00:00`` for an unset date, which Pydantic
|
|
164
|
+
parsed into ``datetime(1, 1, 1)`` — so an unknown installation date or last
|
|
165
|
+
telemetry time rendered as year 1 instead of "unknown"
|
|
166
|
+
(dimplex-controller-py#140). Real timestamps are returned untouched, so
|
|
167
|
+
Pydantic still parses them (including fractional seconds).
|
|
168
|
+
"""
|
|
169
|
+
if isinstance(value, datetime):
|
|
170
|
+
return None if value.replace(tzinfo=None) == _DEFAULT_DATETIME else value
|
|
171
|
+
if isinstance(value, str) and _is_null_datetime(value):
|
|
172
|
+
return None
|
|
173
|
+
return value
|
|
174
|
+
|
|
175
|
+
|
|
147
176
|
class Appliance(BaseModel):
|
|
148
177
|
ApplianceId: str
|
|
149
178
|
ApplianceType: str
|
|
@@ -161,6 +190,12 @@ class Appliance(BaseModel):
|
|
|
161
190
|
FirmwareVersion: str | None = None
|
|
162
191
|
ProductModelExtensions: dict[str, str] | None = None
|
|
163
192
|
|
|
193
|
+
@field_validator("InstallationDate", "LastTelemDate", mode="before")
|
|
194
|
+
@classmethod
|
|
195
|
+
def _map_null_dates(cls, value: Any) -> Any:
|
|
196
|
+
"""Map the cloud's unset-date sentinel to ``None`` rather than year 1."""
|
|
197
|
+
return _map_null_datetime(value)
|
|
198
|
+
|
|
164
199
|
@field_validator("ProductModelExtensions", mode="before")
|
|
165
200
|
@classmethod
|
|
166
201
|
def _coerce_provisioning_extensions(cls, value: Any) -> dict[str, str] | None:
|
|
@@ -224,6 +259,12 @@ class Hub(BaseModel):
|
|
|
224
259
|
IsDefault: bool | None = None
|
|
225
260
|
RoleName: str | None = None
|
|
226
261
|
|
|
262
|
+
@field_validator("HubRegistrationDate", "InstallationDate", "LastTelemDate", mode="before")
|
|
263
|
+
@classmethod
|
|
264
|
+
def _map_null_dates(cls, value: Any) -> Any:
|
|
265
|
+
"""Map the cloud's unset-date sentinel to ``None`` rather than year 1."""
|
|
266
|
+
return _map_null_datetime(value)
|
|
267
|
+
|
|
227
268
|
|
|
228
269
|
class TimerPeriod(BaseModel):
|
|
229
270
|
DayOfWeek: int
|
|
@@ -255,6 +296,31 @@ class TimerModeSettings(BaseModel):
|
|
|
255
296
|
TimerPeriods: list[TimerPeriod] = Field(default_factory=list)
|
|
256
297
|
|
|
257
298
|
|
|
299
|
+
class HeatPumpHwcPeriod(BaseModel):
|
|
300
|
+
"""One period of an ASHW cylinder's weekly schedule.
|
|
301
|
+
|
|
302
|
+
A live cylinder returns one ``{DayOfWeek, StartTime: 0, Mode: 2}`` per day.
|
|
303
|
+
``DayOfWeek`` is .NET's (0 = Sunday). ``StartTime`` is an integer, presumed
|
|
304
|
+
minutes after midnight, and what each ``Mode`` value means is not yet known.
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
DayOfWeek: int
|
|
308
|
+
StartTime: int
|
|
309
|
+
Mode: int
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class HeatPumpHwcSchedule(BaseModel):
|
|
313
|
+
"""``ApiGetTimerModeDetailsForHeatPumpHwcAppliance`` reply.
|
|
314
|
+
|
|
315
|
+
Unlike the room-heater :class:`TimerModeSettings` it has no ``TimerMode``
|
|
316
|
+
and its periods carry a mode instead of an end time and temperature.
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
HubId: str
|
|
320
|
+
ApplianceId: str
|
|
321
|
+
TimerPeriods: list[HeatPumpHwcPeriod] = Field(default_factory=list)
|
|
322
|
+
|
|
323
|
+
|
|
258
324
|
class UserContext(BaseModel):
|
|
259
325
|
Id: str
|
|
260
326
|
EmailAddress: str | None = None
|
|
@@ -286,6 +352,21 @@ class ApplianceStatus(BaseModel):
|
|
|
286
352
|
LockStatus: int | None = None
|
|
287
353
|
ErrorCode: str | None = None
|
|
288
354
|
WarningCode: str | None = None
|
|
355
|
+
# Hot-water cylinder fields, as a live ASHW cylinder (EDL200UK-630RF) sends
|
|
356
|
+
# them. AvailableHotWater above is litres of usable hot water (the app shows
|
|
357
|
+
# it as "336 L Hot water").
|
|
358
|
+
Capacity: int | None = None # tank size, litres
|
|
359
|
+
HeatingUp: bool | None = None
|
|
360
|
+
IsGreenEnergyActive: bool | None = None
|
|
361
|
+
HygieneFrequency: int | None = None # EFrequency, see HygieneFrequency
|
|
362
|
+
HygieneTemperature: float | None = None
|
|
363
|
+
HolidayNumOfDays: int | None = None
|
|
364
|
+
# Present in the same reply, always null on the cylinder seen so far.
|
|
365
|
+
DelayedStartTime: str | None = None
|
|
366
|
+
DsmStatus: int | None = None
|
|
367
|
+
ModifiedPower: float | None = None
|
|
368
|
+
ConnectedChargeProfiles: Any = None
|
|
369
|
+
StandaloneChargeProfiles: Any = None
|
|
289
370
|
|
|
290
371
|
@field_validator("ErrorCode", "WarningCode", mode="before")
|
|
291
372
|
@classmethod
|
|
@@ -222,6 +222,34 @@ def parse_telemetry_points(
|
|
|
222
222
|
return out
|
|
223
223
|
|
|
224
224
|
|
|
225
|
+
def reported_energy(
|
|
226
|
+
points: Any,
|
|
227
|
+
*,
|
|
228
|
+
value_keys: tuple[str, ...] | None = None,
|
|
229
|
+
) -> bool:
|
|
230
|
+
"""Return True when telemetry carries usable points for an appliance.
|
|
231
|
+
|
|
232
|
+
The evidence-based counterpart to
|
|
233
|
+
:attr:`ApplianceCapabilities.energy_meter <.capabilities.ApplianceCapabilities>`,
|
|
234
|
+
which is a family hint derived from the nameplate and is therefore true for
|
|
235
|
+
essentially every appliance — it cannot separate a genuinely metered one from an
|
|
236
|
+
appliance the hub never reports energy for (dimplex-controller-py#113).
|
|
237
|
+
|
|
238
|
+
Pass the appliance's entry from
|
|
239
|
+
:attr:`TsiEnergyReport.ApplianceTelemetryData
|
|
240
|
+
<.models.TsiEnergyReport>` (raw entries are normalised by
|
|
241
|
+
:func:`parse_telemetry_points`, so both raw and already-parsed points are
|
|
242
|
+
accepted). Nothing recognised, an empty list, or a value the parser skips all
|
|
243
|
+
answer ``False`` — which is the question a consumer gating entity creation
|
|
244
|
+
actually has.
|
|
245
|
+
|
|
246
|
+
Only presence is decided here. A caller that also wants the figures should call
|
|
247
|
+
:func:`parse_telemetry_points` or :func:`summarise_energy` once and test the
|
|
248
|
+
result, rather than parse a series twice.
|
|
249
|
+
"""
|
|
250
|
+
return bool(parse_telemetry_points(points, value_keys=value_keys))
|
|
251
|
+
|
|
252
|
+
|
|
225
253
|
def _ensure_aware(ts: datetime, default_tz: tzinfo) -> datetime:
|
|
226
254
|
"""Return ``ts`` with a timezone; naive datetimes are assumed ``default_tz``."""
|
|
227
255
|
if ts.tzinfo is None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dimplex-controller"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.15.1"
|
|
4
4
|
description = "Unofficial async Python client for Dimplex (GDHV IoT) heating appliances — not affiliated with Glen Dimplex"
|
|
5
5
|
license = "MIT"
|
|
6
6
|
license-files = ["LICENSE"]
|
|
@@ -27,8 +27,8 @@ classifiers = [
|
|
|
27
27
|
"Topic :: Home Automation",
|
|
28
28
|
]
|
|
29
29
|
dependencies = [
|
|
30
|
-
"aiohttp>=3.
|
|
31
|
-
"pydantic>=2.
|
|
30
|
+
"aiohttp>=3.9.0,<4",
|
|
31
|
+
"pydantic>=2.0.0,<3",
|
|
32
32
|
]
|
|
33
33
|
|
|
34
34
|
[[project.authors]]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dimplex-controller"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.15.1"
|
|
4
4
|
description = "Unofficial async Python client for Dimplex (GDHV IoT) heating appliances — not affiliated with Glen Dimplex"
|
|
5
5
|
authors = [{ name = "Kieran Roper" }]
|
|
6
6
|
license = "MIT"
|
|
@@ -21,12 +21,21 @@ classifiers = [
|
|
|
21
21
|
"Programming Language :: Python :: 3.14",
|
|
22
22
|
"Topic :: Home Automation",
|
|
23
23
|
]
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
# `>=X,<next-major`), so consumers gain no surprise from the migration.
|
|
24
|
+
# The upper bounds keep the previous caret semantics (a caret range is
|
|
25
|
+
# `>=X,<next-major`), so consumers gain no surprise from the uv migration.
|
|
27
26
|
dependencies = [
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
# Lower bounds are what the code needs, not what uv.lock happens to resolve.
|
|
28
|
+
# They were raised to the locked versions when the project migrated to uv and
|
|
29
|
+
# bumped everything in one pass (#110), which made this library uninstallable
|
|
30
|
+
# alongside Home Assistant: the integration's test stack pins pydantic 2.13.4
|
|
31
|
+
# while this demanded >=2.13.5, so `dimplex-controller>=0.14.0` resolved to
|
|
32
|
+
# nothing and the HA integration could not adopt the 0.14.0 ErrorCode/WarningCode
|
|
33
|
+
# fix (dimplex-controller-hass#256).
|
|
34
|
+
#
|
|
35
|
+
# Nothing here needs either new API: plain ``BaseModel``, ``field_validator``,
|
|
36
|
+
# ``ConfigDict`` and an aiohttp ``ClientSession`` are all 2.0/3.9-era.
|
|
37
|
+
"aiohttp>=3.9.0,<4",
|
|
38
|
+
"pydantic>=2.0.0,<3",
|
|
30
39
|
]
|
|
31
40
|
|
|
32
41
|
[project.urls]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|