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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dimplex-controller
3
- Version: 0.15.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
@@ -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
- # Token expired or missing, try refresh
145
- await self.refresh_tokens()
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
- HTTP_OK,
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
- if resp.status == HTTP_OK:
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=int(duration_minutes) if enable else 0,
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dimplex-controller"
3
- version = "0.15.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"]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dimplex-controller"
3
- version = "0.15.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"