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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dimplex-controller
3
- Version: 0.15.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
- # 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
  )
@@ -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
- if resp.status == HTTP_OK:
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(self, hub_id: str, appliance_id: str) -> TimerModeSettings:
393
- """Get timer details (and mode) for an appliance."""
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": 0, # Required field in request; value ignored on read
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(self, hub_id: str, appliance_id: str) -> TimerModeSettings:
403
- """Return the current timer mode + periods (alias of :meth:`get_appliance_features`)."""
404
- return await self.get_appliance_features(hub_id, appliance_id)
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=int(duration_minutes) if enable else 0,
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dimplex-controller"
3
- version = "0.15.0"
3
+ version = "0.16.0"
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.16.0"
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"