dimplex-controller 0.15.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.15.0 → dimplex_controller-0.15.1}/PKG-INFO +1 -1
- {dimplex_controller-0.15.0 → dimplex_controller-0.15.1}/dimplex_controller/auth.py +20 -3
- {dimplex_controller-0.15.0 → dimplex_controller-0.15.1}/dimplex_controller/client.py +48 -7
- {dimplex_controller-0.15.0 → dimplex_controller-0.15.1}/dimplex_controller/models.py +41 -0
- {dimplex_controller-0.15.0 → dimplex_controller-0.15.1}/pyproject.toml +1 -1
- {dimplex_controller-0.15.0 → dimplex_controller-0.15.1}/pyproject.toml.orig +1 -1
- {dimplex_controller-0.15.0 → dimplex_controller-0.15.1}/LICENSE +0 -0
- {dimplex_controller-0.15.0 → dimplex_controller-0.15.1}/README.md +0 -0
- {dimplex_controller-0.15.0 → dimplex_controller-0.15.1}/dimplex_controller/__init__.py +0 -0
- {dimplex_controller-0.15.0 → dimplex_controller-0.15.1}/dimplex_controller/capabilities.py +0 -0
- {dimplex_controller-0.15.0 → dimplex_controller-0.15.1}/dimplex_controller/cli.py +0 -0
- {dimplex_controller-0.15.0 → dimplex_controller-0.15.1}/dimplex_controller/const.py +0 -0
- {dimplex_controller-0.15.0 → dimplex_controller-0.15.1}/dimplex_controller/exceptions.py +0 -0
- {dimplex_controller-0.15.0 → dimplex_controller-0.15.1}/dimplex_controller/py.typed +0 -0
- {dimplex_controller-0.15.0 → dimplex_controller-0.15.1}/dimplex_controller/telemetry.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dimplex-controller
|
|
3
|
-
Version: 0.15.
|
|
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
|
|
@@ -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
|
)
|
|
@@ -94,6 +95,28 @@ def _clamp_away_temperature(temperature: float) -> float:
|
|
|
94
95
|
return clamped
|
|
95
96
|
|
|
96
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
|
+
|
|
97
120
|
def _coerce_timeout(timeout: float | aiohttp.ClientTimeout | None) -> aiohttp.ClientTimeout | None:
|
|
98
121
|
"""Normalise a timeout value into an :class:`aiohttp.ClientTimeout`.
|
|
99
122
|
|
|
@@ -260,7 +283,11 @@ class DimplexControl:
|
|
|
260
283
|
for attempt in range(attempts):
|
|
261
284
|
try:
|
|
262
285
|
async with self._session.request(method, url, headers=headers, **kwargs) as resp:
|
|
263
|
-
|
|
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:
|
|
264
291
|
return await self._decode_ok_body(resp)
|
|
265
292
|
|
|
266
293
|
text = await resp.text()
|
|
@@ -584,18 +611,27 @@ class DimplexControl:
|
|
|
584
611
|
with ``Status=1`` (on) / ``Status=0`` (off); ``Time`` carries the boost
|
|
585
612
|
duration in minutes and ``Temperature`` the boost target (7–30 °C).
|
|
586
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
|
+
|
|
587
620
|
.. versionchanged:: 0.13.0
|
|
588
621
|
Previously sent ``ApplianceModes=16``, which is **Advance** — the
|
|
589
622
|
appliance advanced its schedule instead of boosting, and the
|
|
590
623
|
requested duration was discarded.
|
|
591
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}")
|
|
592
628
|
await self.set_mode_flag(
|
|
593
629
|
hub_id,
|
|
594
630
|
appliance_ids,
|
|
595
631
|
ApplianceModeFlag.BOOST,
|
|
596
632
|
enable=enable,
|
|
597
633
|
temperature=temperature,
|
|
598
|
-
minutes=
|
|
634
|
+
minutes=minutes if enable else 0,
|
|
599
635
|
)
|
|
600
636
|
|
|
601
637
|
async def clear_boost(
|
|
@@ -763,12 +799,16 @@ class DimplexControl:
|
|
|
763
799
|
the schedule and is rejected with HTTP 403 on Quantum.
|
|
764
800
|
|
|
765
801
|
``Temperature`` is a wire ``byte``, so the value is rounded to whole
|
|
766
|
-
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).
|
|
767
807
|
"""
|
|
768
808
|
payload = {
|
|
769
809
|
"HubId": hub_id,
|
|
770
810
|
"ApplianceIds": appliance_ids,
|
|
771
|
-
"Temperature": int(round(temperature)),
|
|
811
|
+
"Temperature": int(round(_clamp_mode_temperature(temperature))),
|
|
772
812
|
}
|
|
773
813
|
await self._request("POST", "/RemoteControl/SetApplianceSetpointTemperature", json=payload)
|
|
774
814
|
|
|
@@ -785,7 +825,8 @@ class DimplexControl:
|
|
|
785
825
|
``status`` is an :class:`~dimplex_controller.SetbackStatus` byte —
|
|
786
826
|
``ACTIVE``/``INACTIVE`` for a plain setback, with ``DSM_MODE`` and
|
|
787
827
|
``LOCAL_FREQUENCY_CONTROL_ACTIVE`` reserved for demand-side-management
|
|
788
|
-
use.
|
|
828
|
+
use. ``temperature`` is rounded and clamped into 7–30 °C, as
|
|
829
|
+
:meth:`set_appliance_setpoint_temperature` does.
|
|
789
830
|
|
|
790
831
|
Confirmed present in APK 2.26.0 but **not yet validated on live
|
|
791
832
|
hardware**.
|
|
@@ -794,7 +835,7 @@ class DimplexControl:
|
|
|
794
835
|
"HubId": hub_id,
|
|
795
836
|
"ApplianceIds": appliance_ids,
|
|
796
837
|
"Status": int(status),
|
|
797
|
-
"Temperature": int(round(temperature)),
|
|
838
|
+
"Temperature": int(round(_clamp_mode_temperature(temperature))),
|
|
798
839
|
}
|
|
799
840
|
await self._request("POST", "/RemoteControl/SetSetbackTemperature", json=payload)
|
|
800
841
|
|
|
@@ -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
|
|
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
|