dimplex-controller 0.13.0__tar.gz → 0.13.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,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dimplex-controller
3
- Version: 0.13.0
4
- Summary: Python client for Dimplex heating controllers (GDHV IoT)
3
+ Version: 0.13.1
4
+ Summary: Unofficial async Python client for Dimplex (GDHV IoT) heating appliances — not affiliated with Glen Dimplex
5
5
  License: MIT
6
6
  License-File: LICENSE
7
7
  Keywords: dimplex,heating,control,iot,asyncio
@@ -40,14 +40,24 @@ Description-Content-Type: text/markdown
40
40
 
41
41
  ---
42
42
 
43
+ > [!IMPORTANT]
44
+ > **Unofficial library.** Not affiliated with, endorsed by, or supported by Dimplex, Glen
45
+ > Dimplex Heating & Ventilation (GDHV), or the Glen Dimplex Group. This is an independent
46
+ > project built by **reverse-engineering** the official Dimplex Control Android app and the
47
+ > private cloud API it talks to.
48
+ >
49
+ > There is no public or documented API. Dimplex can change or withdraw that API at any time
50
+ > and without notice, which may break this library. Use it at your own risk.
51
+ >
52
+ > No Dimplex code is included or redistributed. "Dimplex", "Quantum", "QRAD" and related
53
+ > marks belong to their respective owners and are used here only to describe compatibility.
54
+
43
55
  ## What does this do?
44
56
 
45
57
  `dimplex-controller-py` is an asynchronous Python client that talks to the GDHV IoT cloud platform. It handles Azure B2C authentication (including automatic token refresh), discovers your Hubs, Zones and Appliances, and lets you read telemetry and send control commands — all from a script or a larger application.
46
58
 
47
59
  It is the engine behind the [Dimplex Hub Home Assistant integration](https://github.com/KRoperUK/dimplex-controller-hass) and is published to PyPI as [`dimplex-controller`](https://pypi.org/project/dimplex-controller/).
48
60
 
49
- > **Note:** This is an unofficial library and is not affiliated with or endorsed by Glen Dimplex Heating & Ventilation (GDHV). Use it at your own risk.
50
-
51
61
  ## Contents
52
62
 
53
63
  - [Features](#features)
@@ -186,8 +196,9 @@ await client.set_open_window_detection(hub_id, [appliance_id], True)
186
196
  # Timed Boost (ApplianceModes=2, Time = minutes)
187
197
  await client.set_boost(hub_id, [appliance_id], temperature=25.0, duration_minutes=60)
188
198
 
189
- # Away until a given moment (ApplianceModes=4). Away is a settable 7–30 °C
190
- # setback and defaults to the 7 °C anti-freeze floor.
199
+ # Away until a given moment (ApplianceModes=4). Away is a settable 7–18 °C
200
+ # setback and defaults to the 7 °C anti-freeze floor. A higher temperature is
201
+ # clamped to 18 °C with a warning, matching what the cloud does to it anyway.
191
202
  from datetime import datetime, timedelta, timezone
192
203
 
193
204
  await client.set_away(
@@ -13,14 +13,24 @@
13
13
 
14
14
  ---
15
15
 
16
+ > [!IMPORTANT]
17
+ > **Unofficial library.** Not affiliated with, endorsed by, or supported by Dimplex, Glen
18
+ > Dimplex Heating & Ventilation (GDHV), or the Glen Dimplex Group. This is an independent
19
+ > project built by **reverse-engineering** the official Dimplex Control Android app and the
20
+ > private cloud API it talks to.
21
+ >
22
+ > There is no public or documented API. Dimplex can change or withdraw that API at any time
23
+ > and without notice, which may break this library. Use it at your own risk.
24
+ >
25
+ > No Dimplex code is included or redistributed. "Dimplex", "Quantum", "QRAD" and related
26
+ > marks belong to their respective owners and are used here only to describe compatibility.
27
+
16
28
  ## What does this do?
17
29
 
18
30
  `dimplex-controller-py` is an asynchronous Python client that talks to the GDHV IoT cloud platform. It handles Azure B2C authentication (including automatic token refresh), discovers your Hubs, Zones and Appliances, and lets you read telemetry and send control commands — all from a script or a larger application.
19
31
 
20
32
  It is the engine behind the [Dimplex Hub Home Assistant integration](https://github.com/KRoperUK/dimplex-controller-hass) and is published to PyPI as [`dimplex-controller`](https://pypi.org/project/dimplex-controller/).
21
33
 
22
- > **Note:** This is an unofficial library and is not affiliated with or endorsed by Glen Dimplex Heating & Ventilation (GDHV). Use it at your own risk.
23
-
24
34
  ## Contents
25
35
 
26
36
  - [Features](#features)
@@ -159,8 +169,9 @@ await client.set_open_window_detection(hub_id, [appliance_id], True)
159
169
  # Timed Boost (ApplianceModes=2, Time = minutes)
160
170
  await client.set_boost(hub_id, [appliance_id], temperature=25.0, duration_minutes=60)
161
171
 
162
- # Away until a given moment (ApplianceModes=4). Away is a settable 7–30 °C
163
- # setback and defaults to the 7 °C anti-freeze floor.
172
+ # Away until a given moment (ApplianceModes=4). Away is a settable 7–18 °C
173
+ # setback and defaults to the 7 °C anti-freeze floor. A higher temperature is
174
+ # clamped to 18 °C with a warning, matching what the cloud does to it anyway.
164
175
  from datetime import datetime, timedelta, timezone
165
176
 
166
177
  await client.set_away(
@@ -1,7 +1,10 @@
1
1
  """Dimplex Controller Client.
2
2
 
3
- Async Python client for the Glen Dimplex Heating & Ventilation (GDHV) cloud
4
- API. See :class:`~dimplex_controller.client.DimplexControl` for the entry
3
+ Unofficial async Python client for the Glen Dimplex Heating & Ventilation
4
+ (GDHV) cloud API. Not affiliated with, endorsed by, or supported by Dimplex
5
+ or the Glen Dimplex Group: the protocol was recovered by reverse-engineering
6
+ the official Dimplex Control Android app, so Dimplex may change or withdraw
7
+ it without notice. See :class:`~dimplex_controller.client.DimplexControl` for the entry
5
8
  point and :meth:`DimplexControl.get_appliance_overview` for the read path
6
9
  used by the Home Assistant integration.
7
10
 
@@ -15,6 +18,8 @@ from .auth import TokenBundle, TokenListener
15
18
  from .capabilities import ApplianceCapabilities, capabilities_for
16
19
  from .client import DimplexControl
17
20
  from .const import (
21
+ AWAY_TEMP_MAX,
22
+ AWAY_TEMP_MIN,
18
23
  DEFAULT_AWAY_TEMPERATURE,
19
24
  DEFAULT_BOOST_TEMPERATURE,
20
25
  FROST_TEMPERATURE,
@@ -84,6 +89,8 @@ __all__ = [
84
89
  "TsiEnergyReport",
85
90
  "DEFAULT_AWAY_TEMPERATURE",
86
91
  "DEFAULT_BOOST_TEMPERATURE",
92
+ "AWAY_TEMP_MIN",
93
+ "AWAY_TEMP_MAX",
87
94
  "FROST_TEMPERATURE",
88
95
  "MODE_TEMP_MIN",
89
96
  "MODE_TEMP_MAX",
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
  from dataclasses import dataclass
10
10
  from typing import Any
11
11
 
12
- from .const import FROST_TEMPERATURE, MODE_TEMP_MAX, MODE_TEMP_MIN
12
+ from .const import AWAY_TEMP_MAX, AWAY_TEMP_MIN, FROST_TEMPERATURE, MODE_TEMP_MAX, MODE_TEMP_MIN
13
13
  from .models import Appliance, ApplianceStatus, AutomaticProvisioning, ProductModel
14
14
 
15
15
  # Default boost lengths (minutes) offered by the mobile app for most heaters.
@@ -47,6 +47,11 @@ class ApplianceCapabilities:
47
47
  climate: bool = True
48
48
  min_temp: float = MODE_TEMP_MIN
49
49
  max_temp: float = MODE_TEMP_MAX
50
+ # Away is the one carousel with its own bounds: it shares the 7 °C floor but
51
+ # stops at 18 °C rather than 30 °C (dimplex-controller-py#98). Consumers
52
+ # should gate the Away picker on these rather than on min_temp/max_temp.
53
+ away_min_temp: float = AWAY_TEMP_MIN
54
+ away_max_temp: float = AWAY_TEMP_MAX
50
55
  frost_temp: float = FROST_TEMPERATURE
51
56
  default_boost_minutes: int = DEFAULT_BOOST_MINUTES
52
57
  boost_durations: tuple[int, ...] = DEFAULT_BOOST_DURATIONS
@@ -83,6 +88,8 @@ class ApplianceCapabilities:
83
88
  "climate": self.climate,
84
89
  "min_temp": self.min_temp,
85
90
  "max_temp": self.max_temp,
91
+ "away_min_temp": self.away_min_temp,
92
+ "away_max_temp": self.away_max_temp,
86
93
  "frost_temp": self.frost_temp,
87
94
  "default_boost_minutes": self.default_boost_minutes,
88
95
  "boost_durations": list(self.boost_durations),
@@ -12,6 +12,8 @@ import aiohttp
12
12
  from .auth import AuthManager, TokenBundle, TokenListener
13
13
  from .capabilities import ApplianceCapabilities, capabilities_for
14
14
  from .const import (
15
+ AWAY_TEMP_MAX,
16
+ AWAY_TEMP_MIN,
15
17
  BASE_URL,
16
18
  DEFAULT_AWAY_TEMPERATURE,
17
19
  DEFAULT_BOOST_TEMPERATURE,
@@ -70,6 +72,26 @@ _RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
70
72
  DEFAULT_TIMEOUT = 30.0
71
73
 
72
74
 
75
+ def _clamp_away_temperature(temperature: float) -> float:
76
+ """Clamp an Away target into the mode's own 7–18 °C range.
77
+
78
+ The cloud reduces a higher value silently, so clamping here keeps the value
79
+ the caller asked for and the value the appliance ends up at in agreement,
80
+ and the warning makes the adjustment visible.
81
+ """
82
+ value = float(temperature)
83
+ clamped = min(max(value, AWAY_TEMP_MIN), AWAY_TEMP_MAX)
84
+ if clamped != value:
85
+ _LOGGER.warning(
86
+ "Away temperature %.1f °C is outside the %s–%s °C the app offers for Away; using %.1f °C",
87
+ value,
88
+ AWAY_TEMP_MIN,
89
+ AWAY_TEMP_MAX,
90
+ clamped,
91
+ )
92
+ return clamped
93
+
94
+
73
95
  def _coerce_timeout(timeout: float | aiohttp.ClientTimeout | None) -> aiohttp.ClientTimeout | None:
74
96
  """Normalise a timeout value into an :class:`aiohttp.ClientTimeout`.
75
97
 
@@ -325,7 +347,7 @@ class DimplexControl:
325
347
  """Get details for a specific zone."""
326
348
  payload = {"HubId": hub_id, "ZoneId": zone_id}
327
349
  data = await self._request("POST", "/Zones/GetZone", json=payload)
328
- return Zone.model_validate(data) # type: ignore[no-any-return]
350
+ return Zone.model_validate(data)
329
351
 
330
352
  async def get_appliance_overview(self, hub_id: str, appliance_ids: list[str]) -> list[ApplianceStatus]:
331
353
  """Get status overview for specific appliances.
@@ -351,7 +373,7 @@ class DimplexControl:
351
373
  async def get_user_context(self) -> UserContext:
352
374
  """Get user profile/context."""
353
375
  data = await self._request("GET", "/Identity/GetUserContext")
354
- return UserContext.model_validate(data) # type: ignore[no-any-return]
376
+ return UserContext.model_validate(data)
355
377
 
356
378
  async def get_product_models(self) -> list[ProductModel]:
357
379
  """Return the cloud product catalogue (models + provisioning metadata).
@@ -371,7 +393,7 @@ class DimplexControl:
371
393
  "TimerMode": 0, # Required field in request; value ignored on read
372
394
  }
373
395
  data = await self._request("POST", "/RemoteControl/GetTimerModeDetailsForAppliance", json=payload)
374
- return TimerModeSettings.model_validate(data) # type: ignore[no-any-return]
396
+ return TimerModeSettings.model_validate(data)
375
397
 
376
398
  async def get_schedule(self, hub_id: str, appliance_id: str) -> TimerModeSettings:
377
399
  """Return the current timer mode + periods (alias of :meth:`get_appliance_features`)."""
@@ -591,15 +613,26 @@ class DimplexControl:
591
613
  """Enable or disable Away mode.
592
614
 
593
615
  Sends ``ApplianceModes=4`` (:attr:`~dimplex_controller.ApplianceModeFlag.AWAY`).
594
- Away is a *settable* setback: the app offers 7–30 °C and defaults to the
595
- 7 °C anti-freeze floor, so a low ``temperature`` is the normal case
596
- rather than a bug.
616
+ Away is a *settable* setback: the app offers 7–18 °C
617
+ (:data:`~dimplex_controller.AWAY_TEMP_MIN` / :data:`~dimplex_controller.AWAY_TEMP_MAX`)
618
+ and defaults to the 7 °C anti-freeze floor, so a low ``temperature`` is
619
+ the normal case rather than a bug.
597
620
 
598
621
  ``until`` is the "away until" datetime the app sends in ``Date``. Pass a
599
622
  :class:`~datetime.datetime` or an ISO-8601 string. ``number_of_days`` is
600
623
  retained for backwards compatibility and, when ``until`` is omitted, is
601
624
  converted into an equivalent ``Date``.
602
625
 
626
+ When engaging Away, ``temperature`` is clamped into the Away range and a
627
+ warning logged if it had to be: the cloud silently reduces a higher
628
+ value, so clamping locally makes the outcome the caller actually gets
629
+ visible rather than surprising (dimplex-controller-py#98). The clamp is
630
+ skipped when clearing, where the temperature is inert.
631
+
632
+ .. versionchanged:: 0.13.1
633
+ ``temperature`` is clamped to ``AWAY_TEMP_MIN``..``AWAY_TEMP_MAX``
634
+ when ``enable`` is true.
635
+
603
636
  .. versionchanged:: 0.13.0
604
637
  Previously sent ``ApplianceModes=32``, which is **FrostProtect** —
605
638
  the appliance was pinned to the fixed 7 °C frost setpoint and the
@@ -607,6 +640,8 @@ class DimplexControl:
607
640
  Away duration now travels in ``Date`` as the app does, not only in
608
641
  ``NumberOfDays``.
609
642
  """
643
+ if enable:
644
+ temperature = _clamp_away_temperature(temperature)
610
645
  days = int(number_of_days)
611
646
  if until is None and days > 0:
612
647
  until = datetime.now(timezone.utc) + timedelta(days=days)
@@ -876,7 +911,7 @@ class DimplexControl:
876
911
  "/RemoteControl/ApiGetTimerModeDetailsForHeatPumpHwcAppliance",
877
912
  json=payload,
878
913
  )
879
- return TimerModeSettings.model_validate(data) # type: ignore[no-any-return]
914
+ return TimerModeSettings.model_validate(data)
880
915
 
881
916
  async def set_heat_pump_hot_water_schedule(self, settings: TimerModeSettings) -> TimerModeSettings:
882
917
  """Write an ASHW cylinder's schedule periods.
@@ -26,15 +26,24 @@ B2C_POLICY = "B2C_1A_DimplexControlSignupSignin"
26
26
  # recovered from Dimplex Control APK 2.26.0.
27
27
  # See ``docs/decompiled-api-reference.md``.
28
28
 
29
- # The mode carousels (Away / Boost / Frost / Manual / Eco) all offer 7–30 °C.
29
+ # The mode carousels (Boost / Frost / Manual / Eco) offer 7–30 °C. Away shares
30
+ # the floor but *not* the ceiling — see AWAY_TEMP_MAX below.
30
31
  MODE_TEMP_MIN = 7.0
31
32
  MODE_TEMP_MAX = 30.0
32
33
 
34
+ # Away has its own, narrower ceiling. The app's Away picker will not offer above
35
+ # 18 °C, and a live test against a Quantum requesting 25 °C produced 18 °C in the
36
+ # cloud overview, the app and the appliance's own panel — so the earlier reading
37
+ # of ``UpdateTempRange`` that folded Away into the shared 7–30 carousel was
38
+ # over-generalised (dimplex-controller-py#98).
39
+ AWAY_TEMP_MIN = 7.0
40
+ AWAY_TEMP_MAX = 18.0
41
+
33
42
  # Frost protection is always driven at the anti-freeze floor.
34
43
  FROST_TEMPERATURE = 7.0
35
44
 
36
45
  # Away defaults to the frost floor (it is an anti-freeze setback by default)
37
- # but is user-settable across MODE_TEMP_MIN..MODE_TEMP_MAX.
46
+ # but is user-settable across AWAY_TEMP_MIN..AWAY_TEMP_MAX.
38
47
  DEFAULT_AWAY_TEMPERATURE = FROST_TEMPERATURE
39
48
 
40
49
  # Boost's default in the app's picker.
@@ -139,7 +139,7 @@ class ProductModel(BaseModel):
139
139
  if not raw:
140
140
  return None
141
141
  try:
142
- return AutomaticProvisioning.model_validate_json(raw) # type: ignore[no-any-return]
142
+ return AutomaticProvisioning.model_validate_json(raw)
143
143
  except (json.JSONDecodeError, ValueError):
144
144
  return None
145
145
 
@@ -178,7 +178,7 @@ class Appliance(BaseModel):
178
178
  if not raw:
179
179
  return None
180
180
  try:
181
- return AutomaticProvisioning.model_validate_json(raw) # type: ignore[no-any-return]
181
+ return AutomaticProvisioning.model_validate_json(raw)
182
182
  except (json.JSONDecodeError, ValueError):
183
183
  return None
184
184
 
@@ -425,7 +425,7 @@ class TsiEnergyReport(BaseModel):
425
425
  """
426
426
 
427
427
  HubId: str
428
- ApplianceTelemetryData: dict[str, list] = Field(default_factory=dict)
428
+ ApplianceTelemetryData: dict[str, list[Any]] = Field(default_factory=dict)
429
429
 
430
430
 
431
431
  # `TsiReportType` integer values understood by the API. We do not know the
@@ -285,7 +285,7 @@ def summarise_energy(
285
285
  and isinstance(points[0], tuple)
286
286
  and len(points[0]) == 2
287
287
  ):
288
- parsed = list(points) # type: ignore[arg-type]
288
+ parsed = list(points)
289
289
  else:
290
290
  parsed = parse_telemetry_points(points, value_keys=value_keys)
291
291
 
@@ -1,7 +1,7 @@
1
1
  [tool.poetry]
2
2
  name = "dimplex-controller"
3
- version = "0.13.0"
4
- description = "Python client for Dimplex heating controllers (GDHV IoT)"
3
+ version = "0.13.1"
4
+ description = "Unofficial async Python client for Dimplex (GDHV IoT) heating appliances — not affiliated with Glen Dimplex"
5
5
  authors = ["Kieran Roper"]
6
6
  license = "MIT"
7
7
  readme = "README.md"
@@ -55,11 +55,16 @@ ignore = ["E501"]
55
55
  python_version = "3.10"
56
56
  files = ["dimplex_controller"]
57
57
  ignore_missing_imports = true
58
- # Incrementally strict — these catch real bugs without requiring a full rewrite.
59
- disallow_untyped_defs = true
60
- warn_return_any = true
61
- warn_unused_configs = true
62
- warn_redundant_casts = true
58
+ # `strict` rather than the four flags this used to carry (disallow_untyped_defs,
59
+ # warn_return_any, warn_unused_configs, warn_redundant_casts). The library is the
60
+ # layer the Home Assistant integration trusts for models, capabilities and error
61
+ # types, and that consumer runs strict mypy over its own code — so a library
62
+ # checked more loosely could hand it types that do not hold
63
+ # (dimplex-controller-hass#202).
64
+ #
65
+ # Raising it surfaced seven stale `# type: ignore` comments, all of which mypy
66
+ # reported as unused, plus one missing generic argument. No real type errors.
67
+ strict = true
63
68
 
64
69
  [tool.pytest.ini_options]
65
70
  # All async tests use asyncio; "auto" means new tests don't each need the