dimplex-controller 0.15.0__tar.gz → 0.16.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dimplex_controller-0.15.0 → dimplex_controller-0.16.0}/PKG-INFO +1 -1
- {dimplex_controller-0.15.0 → dimplex_controller-0.16.0}/dimplex_controller/auth.py +20 -3
- {dimplex_controller-0.15.0 → dimplex_controller-0.16.0}/dimplex_controller/client.py +96 -13
- {dimplex_controller-0.15.0 → dimplex_controller-0.16.0}/dimplex_controller/models.py +48 -0
- {dimplex_controller-0.15.0 → dimplex_controller-0.16.0}/pyproject.toml +1 -1
- {dimplex_controller-0.15.0 → dimplex_controller-0.16.0}/pyproject.toml.orig +1 -1
- {dimplex_controller-0.15.0 → dimplex_controller-0.16.0}/LICENSE +0 -0
- {dimplex_controller-0.15.0 → dimplex_controller-0.16.0}/README.md +0 -0
- {dimplex_controller-0.15.0 → dimplex_controller-0.16.0}/dimplex_controller/__init__.py +0 -0
- {dimplex_controller-0.15.0 → dimplex_controller-0.16.0}/dimplex_controller/capabilities.py +0 -0
- {dimplex_controller-0.15.0 → dimplex_controller-0.16.0}/dimplex_controller/cli.py +0 -0
- {dimplex_controller-0.15.0 → dimplex_controller-0.16.0}/dimplex_controller/const.py +0 -0
- {dimplex_controller-0.15.0 → dimplex_controller-0.16.0}/dimplex_controller/exceptions.py +0 -0
- {dimplex_controller-0.15.0 → dimplex_controller-0.16.0}/dimplex_controller/py.typed +0 -0
- {dimplex_controller-0.15.0 → dimplex_controller-0.16.0}/dimplex_controller/telemetry.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dimplex-controller
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.16.0
|
|
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
|
|
@@ -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.",
|
|
@@ -26,7 +26,8 @@ from .const import (
|
|
|
26
26
|
HEADER_DEVICE_OS,
|
|
27
27
|
HEADER_DEVICE_VERSION,
|
|
28
28
|
HEADER_USER_AGENT,
|
|
29
|
-
|
|
29
|
+
MODE_TEMP_MAX,
|
|
30
|
+
MODE_TEMP_MIN,
|
|
30
31
|
NO_SETPOINT_SENTINEL,
|
|
31
32
|
NULL_DATETIME,
|
|
32
33
|
)
|
|
@@ -41,6 +42,7 @@ from .models import (
|
|
|
41
42
|
Hub,
|
|
42
43
|
HygieneFrequency,
|
|
43
44
|
ProductModel,
|
|
45
|
+
ScheduleProfile,
|
|
44
46
|
SetbackStatus,
|
|
45
47
|
TimerMode,
|
|
46
48
|
TimerModeSettings,
|
|
@@ -94,6 +96,28 @@ def _clamp_away_temperature(temperature: float) -> float:
|
|
|
94
96
|
return clamped
|
|
95
97
|
|
|
96
98
|
|
|
99
|
+
def _clamp_mode_temperature(temperature: float) -> float:
|
|
100
|
+
"""Clamp a setpoint into the 7–30 °C the app's mode carousels offer.
|
|
101
|
+
|
|
102
|
+
The wire field is a byte, so an out-of-range value used to be sent verbatim
|
|
103
|
+
and only reduced (or ignored) by the cloud — the write reported success for
|
|
104
|
+
a setpoint the appliance never reached. Clamping, as Away already does,
|
|
105
|
+
keeps the requested value and the applied value in agreement and makes the
|
|
106
|
+
adjustment visible (dimplex-controller-py#141).
|
|
107
|
+
"""
|
|
108
|
+
value = float(temperature)
|
|
109
|
+
clamped = min(max(value, MODE_TEMP_MIN), MODE_TEMP_MAX)
|
|
110
|
+
if clamped != value:
|
|
111
|
+
_LOGGER.warning(
|
|
112
|
+
"Setpoint %.1f °C is outside the %s–%s °C the app offers; using %.1f °C",
|
|
113
|
+
value,
|
|
114
|
+
MODE_TEMP_MIN,
|
|
115
|
+
MODE_TEMP_MAX,
|
|
116
|
+
clamped,
|
|
117
|
+
)
|
|
118
|
+
return clamped
|
|
119
|
+
|
|
120
|
+
|
|
97
121
|
def _coerce_timeout(timeout: float | aiohttp.ClientTimeout | None) -> aiohttp.ClientTimeout | None:
|
|
98
122
|
"""Normalise a timeout value into an :class:`aiohttp.ClientTimeout`.
|
|
99
123
|
|
|
@@ -260,7 +284,11 @@ class DimplexControl:
|
|
|
260
284
|
for attempt in range(attempts):
|
|
261
285
|
try:
|
|
262
286
|
async with self._session.request(method, url, headers=headers, **kwargs) as resp:
|
|
263
|
-
|
|
287
|
+
# Any 2xx is success. Control endpoints may answer 204 No
|
|
288
|
+
# Content, and an empty body is already what
|
|
289
|
+
# _decode_ok_body handles; gating on exactly 200 raised a
|
|
290
|
+
# successful write as an API error (dimplex-controller-py#141).
|
|
291
|
+
if 200 <= resp.status < 300:
|
|
264
292
|
return await self._decode_ok_body(resp)
|
|
265
293
|
|
|
266
294
|
text = await resp.text()
|
|
@@ -389,19 +417,38 @@ class DimplexControl:
|
|
|
389
417
|
return []
|
|
390
418
|
return [ProductModel.model_validate(item) for item in data]
|
|
391
419
|
|
|
392
|
-
async def get_appliance_features(
|
|
393
|
-
|
|
420
|
+
async def get_appliance_features(
|
|
421
|
+
self,
|
|
422
|
+
hub_id: str,
|
|
423
|
+
appliance_id: str,
|
|
424
|
+
*,
|
|
425
|
+
timer_mode: int | ScheduleProfile = 0,
|
|
426
|
+
) -> TimerModeSettings:
|
|
427
|
+
"""Get a profile's timer periods for an appliance.
|
|
428
|
+
|
|
429
|
+
``timer_mode`` selects which profile's periods come back (0 = user
|
|
430
|
+
timer, 1 = home all day, 2 = out all day); the app fetches each profile
|
|
431
|
+
separately and caches one entry per value. The response ``TimerMode``
|
|
432
|
+
echoes the request — it is not the active schedule, which lives in
|
|
433
|
+
``ApplianceStatus.StatusTwo`` (dimplex-controller-py#149).
|
|
434
|
+
"""
|
|
394
435
|
payload = {
|
|
395
436
|
"HubId": hub_id,
|
|
396
437
|
"ApplianceId": appliance_id,
|
|
397
|
-
"TimerMode":
|
|
438
|
+
"TimerMode": int(timer_mode),
|
|
398
439
|
}
|
|
399
440
|
data = await self._request("POST", "/RemoteControl/GetTimerModeDetailsForAppliance", json=payload)
|
|
400
441
|
return TimerModeSettings.model_validate(data)
|
|
401
442
|
|
|
402
|
-
async def get_schedule(
|
|
403
|
-
|
|
404
|
-
|
|
443
|
+
async def get_schedule(
|
|
444
|
+
self,
|
|
445
|
+
hub_id: str,
|
|
446
|
+
appliance_id: str,
|
|
447
|
+
*,
|
|
448
|
+
timer_mode: int | ScheduleProfile = 0,
|
|
449
|
+
) -> TimerModeSettings:
|
|
450
|
+
"""Return a profile's timer periods (alias of :meth:`get_appliance_features`)."""
|
|
451
|
+
return await self.get_appliance_features(hub_id, appliance_id, timer_mode=timer_mode)
|
|
405
452
|
|
|
406
453
|
async def _write_timer_settings(self, settings: TimerModeSettings) -> TimerModeSettings:
|
|
407
454
|
"""POST a full :class:`TimerModeSettings` payload and return it."""
|
|
@@ -545,6 +592,7 @@ class DimplexControl:
|
|
|
545
592
|
until: datetime | str | None = None,
|
|
546
593
|
number_of_days: int = 0,
|
|
547
594
|
frequency: int | HygieneFrequency = 0,
|
|
595
|
+
status_two: int | ScheduleProfile = 0,
|
|
548
596
|
endpoint: str = "/RemoteControl/SetApplianceMode",
|
|
549
597
|
) -> None:
|
|
550
598
|
"""Engage or clear a single :class:`ApplianceModeFlag`.
|
|
@@ -561,6 +609,7 @@ class DimplexControl:
|
|
|
561
609
|
Date=_iso_away_until(until) if enable else NULL_DATETIME,
|
|
562
610
|
NumberOfDays=int(number_of_days) if enable else 0,
|
|
563
611
|
Frequency=int(frequency) if enable else 0,
|
|
612
|
+
StatusTwo=int(status_two) if enable else 0,
|
|
564
613
|
)
|
|
565
614
|
payload = {
|
|
566
615
|
"Settings": settings.model_dump(mode="json"),
|
|
@@ -584,18 +633,27 @@ class DimplexControl:
|
|
|
584
633
|
with ``Status=1`` (on) / ``Status=0`` (off); ``Time`` carries the boost
|
|
585
634
|
duration in minutes and ``Temperature`` the boost target (7–30 °C).
|
|
586
635
|
|
|
636
|
+
A non-positive ``duration_minutes`` raises :class:`ValueError` when
|
|
637
|
+
engaging: the cloud accepted ``Time=0`` and the appliance simply did not
|
|
638
|
+
boost, so the caller was told a boost had started that never would
|
|
639
|
+
(dimplex-controller-py#141). Clearing (``enable=False``) is unaffected —
|
|
640
|
+
it sends ``Time=0`` by design.
|
|
641
|
+
|
|
587
642
|
.. versionchanged:: 0.13.0
|
|
588
643
|
Previously sent ``ApplianceModes=16``, which is **Advance** — the
|
|
589
644
|
appliance advanced its schedule instead of boosting, and the
|
|
590
645
|
requested duration was discarded.
|
|
591
646
|
"""
|
|
647
|
+
minutes = int(duration_minutes)
|
|
648
|
+
if enable and minutes <= 0:
|
|
649
|
+
raise ValueError(f"boost duration must be a positive number of minutes, got {duration_minutes!r}")
|
|
592
650
|
await self.set_mode_flag(
|
|
593
651
|
hub_id,
|
|
594
652
|
appliance_ids,
|
|
595
653
|
ApplianceModeFlag.BOOST,
|
|
596
654
|
enable=enable,
|
|
597
655
|
temperature=temperature,
|
|
598
|
-
minutes=
|
|
656
|
+
minutes=minutes if enable else 0,
|
|
599
657
|
)
|
|
600
658
|
|
|
601
659
|
async def clear_boost(
|
|
@@ -752,6 +810,26 @@ class DimplexControl:
|
|
|
752
810
|
temperature=temperature,
|
|
753
811
|
)
|
|
754
812
|
|
|
813
|
+
async def set_schedule_profile(
|
|
814
|
+
self,
|
|
815
|
+
hub_id: str,
|
|
816
|
+
appliance_ids: list[str],
|
|
817
|
+
profile: int | ScheduleProfile,
|
|
818
|
+
) -> None:
|
|
819
|
+
"""Apply a schedule profile (User timer / Home all day / Out all day).
|
|
820
|
+
|
|
821
|
+
Sends ``ApplianceModes=1`` (:attr:`TIMER_MODE`) with ``Status=1`` and
|
|
822
|
+
the profile in ``StatusTwo`` — the exact payload the official app's
|
|
823
|
+
schedule picker sends (dimplex-controller-py#147). Accepts a
|
|
824
|
+
:class:`ScheduleProfile` or its int value.
|
|
825
|
+
"""
|
|
826
|
+
await self.set_mode_flag(
|
|
827
|
+
hub_id,
|
|
828
|
+
appliance_ids,
|
|
829
|
+
ApplianceModeFlag.TIMER_MODE,
|
|
830
|
+
status_two=int(profile),
|
|
831
|
+
)
|
|
832
|
+
|
|
755
833
|
async def set_appliance_setpoint_temperature(
|
|
756
834
|
self, hub_id: str, appliance_ids: list[str], temperature: float
|
|
757
835
|
) -> None:
|
|
@@ -763,12 +841,16 @@ class DimplexControl:
|
|
|
763
841
|
the schedule and is rejected with HTTP 403 on Quantum.
|
|
764
842
|
|
|
765
843
|
``Temperature`` is a wire ``byte``, so the value is rounded to whole
|
|
766
|
-
degrees
|
|
844
|
+
degrees, and it is clamped into the 7–30 °C range the app offers
|
|
845
|
+
(:data:`~dimplex_controller.MODE_TEMP_MIN` /
|
|
846
|
+
:data:`~dimplex_controller.MODE_TEMP_MAX`) with a warning when it is
|
|
847
|
+
outside — the cloud silently reduces such a value
|
|
848
|
+
(dimplex-controller-py#141).
|
|
767
849
|
"""
|
|
768
850
|
payload = {
|
|
769
851
|
"HubId": hub_id,
|
|
770
852
|
"ApplianceIds": appliance_ids,
|
|
771
|
-
"Temperature": int(round(temperature)),
|
|
853
|
+
"Temperature": int(round(_clamp_mode_temperature(temperature))),
|
|
772
854
|
}
|
|
773
855
|
await self._request("POST", "/RemoteControl/SetApplianceSetpointTemperature", json=payload)
|
|
774
856
|
|
|
@@ -785,7 +867,8 @@ class DimplexControl:
|
|
|
785
867
|
``status`` is an :class:`~dimplex_controller.SetbackStatus` byte —
|
|
786
868
|
``ACTIVE``/``INACTIVE`` for a plain setback, with ``DSM_MODE`` and
|
|
787
869
|
``LOCAL_FREQUENCY_CONTROL_ACTIVE`` reserved for demand-side-management
|
|
788
|
-
use.
|
|
870
|
+
use. ``temperature`` is rounded and clamped into 7–30 °C, as
|
|
871
|
+
:meth:`set_appliance_setpoint_temperature` does.
|
|
789
872
|
|
|
790
873
|
Confirmed present in APK 2.26.0 but **not yet validated on live
|
|
791
874
|
hardware**.
|
|
@@ -794,7 +877,7 @@ class DimplexControl:
|
|
|
794
877
|
"HubId": hub_id,
|
|
795
878
|
"ApplianceIds": appliance_ids,
|
|
796
879
|
"Status": int(status),
|
|
797
|
-
"Temperature": int(round(temperature)),
|
|
880
|
+
"Temperature": int(round(_clamp_mode_temperature(temperature))),
|
|
798
881
|
}
|
|
799
882
|
await self._request("POST", "/RemoteControl/SetSetbackTemperature", json=payload)
|
|
800
883
|
|
|
@@ -15,6 +15,13 @@ class TimerMode(IntEnum):
|
|
|
15
15
|
|
|
16
16
|
Exact product names vary by appliance firmware; these are the values used
|
|
17
17
|
by the Dimplex Control mobile client and reverse-engineered traffic.
|
|
18
|
+
|
|
19
|
+
.. caution::
|
|
20
|
+
App 2.29.1 uses 0/1/2 here as the *profile* domain (user timer, home all
|
|
21
|
+
day, out all day) and has no manual/frost/off timer modes — those go
|
|
22
|
+
through ``SetApplianceMode`` mode bits instead. ``MANUAL``,
|
|
23
|
+
``FROST_PROTECTION`` and ``OFF`` are therefore unverified in that client
|
|
24
|
+
and kept only because consumers map them (dimplex-controller-py#149).
|
|
18
25
|
"""
|
|
19
26
|
|
|
20
27
|
USER_TIMER = 0
|
|
@@ -144,6 +151,35 @@ class ProductModel(BaseModel):
|
|
|
144
151
|
return None
|
|
145
152
|
|
|
146
153
|
|
|
154
|
+
_DEFAULT_DATETIME = datetime(1, 1, 1)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _is_null_datetime(value: str) -> bool:
|
|
158
|
+
"""True when ``value`` is the cloud's ``default(DateTime)`` sentinel."""
|
|
159
|
+
text = value.strip()
|
|
160
|
+
if not text.startswith(NULL_DATETIME):
|
|
161
|
+
return False
|
|
162
|
+
# Tolerate .NET's optional fractional seconds, ``Z`` suffix or offset, e.g.
|
|
163
|
+
# ``0001-01-01T00:00:00Z`` or ``0001-01-01T00:00:00.0000000+00:00``.
|
|
164
|
+
return not text[len(NULL_DATETIME) :].strip(".0Z+:")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _map_null_datetime(value: Any) -> Any:
|
|
168
|
+
"""Return ``None`` for the .NET null datetime, leaving real values alone.
|
|
169
|
+
|
|
170
|
+
The cloud sends ``0001-01-01T00:00:00`` for an unset date, which Pydantic
|
|
171
|
+
parsed into ``datetime(1, 1, 1)`` — so an unknown installation date or last
|
|
172
|
+
telemetry time rendered as year 1 instead of "unknown"
|
|
173
|
+
(dimplex-controller-py#140). Real timestamps are returned untouched, so
|
|
174
|
+
Pydantic still parses them (including fractional seconds).
|
|
175
|
+
"""
|
|
176
|
+
if isinstance(value, datetime):
|
|
177
|
+
return None if value.replace(tzinfo=None) == _DEFAULT_DATETIME else value
|
|
178
|
+
if isinstance(value, str) and _is_null_datetime(value):
|
|
179
|
+
return None
|
|
180
|
+
return value
|
|
181
|
+
|
|
182
|
+
|
|
147
183
|
class Appliance(BaseModel):
|
|
148
184
|
ApplianceId: str
|
|
149
185
|
ApplianceType: str
|
|
@@ -161,6 +197,12 @@ class Appliance(BaseModel):
|
|
|
161
197
|
FirmwareVersion: str | None = None
|
|
162
198
|
ProductModelExtensions: dict[str, str] | None = None
|
|
163
199
|
|
|
200
|
+
@field_validator("InstallationDate", "LastTelemDate", mode="before")
|
|
201
|
+
@classmethod
|
|
202
|
+
def _map_null_dates(cls, value: Any) -> Any:
|
|
203
|
+
"""Map the cloud's unset-date sentinel to ``None`` rather than year 1."""
|
|
204
|
+
return _map_null_datetime(value)
|
|
205
|
+
|
|
164
206
|
@field_validator("ProductModelExtensions", mode="before")
|
|
165
207
|
@classmethod
|
|
166
208
|
def _coerce_provisioning_extensions(cls, value: Any) -> dict[str, str] | None:
|
|
@@ -224,6 +266,12 @@ class Hub(BaseModel):
|
|
|
224
266
|
IsDefault: bool | None = None
|
|
225
267
|
RoleName: str | None = None
|
|
226
268
|
|
|
269
|
+
@field_validator("HubRegistrationDate", "InstallationDate", "LastTelemDate", mode="before")
|
|
270
|
+
@classmethod
|
|
271
|
+
def _map_null_dates(cls, value: Any) -> Any:
|
|
272
|
+
"""Map the cloud's unset-date sentinel to ``None`` rather than year 1."""
|
|
273
|
+
return _map_null_datetime(value)
|
|
274
|
+
|
|
227
275
|
|
|
228
276
|
class TimerPeriod(BaseModel):
|
|
229
277
|
DayOfWeek: int
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|