dimplex-controller 0.13.0__tar.gz → 0.14.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,15 +1,13 @@
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)
5
- License: MIT
6
- License-File: LICENSE
3
+ Version: 0.14.0
4
+ Summary: Unofficial async Python client for Dimplex (GDHV IoT) heating appliances — not affiliated with Glen Dimplex
7
5
  Keywords: dimplex,heating,control,iot,asyncio
8
6
  Author: Kieran Roper
9
- Requires-Python: >=3.10,<4.0
7
+ License-Expression: MIT
8
+ License-File: LICENSE
10
9
  Classifier: Development Status :: 5 - Production/Stable
11
10
  Classifier: Intended Audience :: Developers
12
- Classifier: License :: OSI Approved :: MIT License
13
11
  Classifier: Natural Language :: English
14
12
  Classifier: Operating System :: OS Independent
15
13
  Classifier: Programming Language :: Python :: 3
@@ -19,8 +17,9 @@ Classifier: Programming Language :: Python :: 3.12
19
17
  Classifier: Programming Language :: Python :: 3.13
20
18
  Classifier: Programming Language :: Python :: 3.14
21
19
  Classifier: Topic :: Home Automation
22
- Requires-Dist: aiohttp (>=3.9.0,<4.0.0)
23
- Requires-Dist: pydantic (>=2.0.0,<3.0.0)
20
+ Requires-Dist: aiohttp>=3.14.3,<4
21
+ Requires-Dist: pydantic>=2.13.5,<3
22
+ Requires-Python: >=3.10, <4.0
24
23
  Project-URL: Homepage, https://github.com/KRoperUK/dimplex-controller-py
25
24
  Project-URL: Repository, https://github.com/KRoperUK/dimplex-controller-py
26
25
  Description-Content-Type: text/markdown
@@ -40,14 +39,24 @@ Description-Content-Type: text/markdown
40
39
 
41
40
  ---
42
41
 
42
+ > [!IMPORTANT]
43
+ > **Unofficial library.** Not affiliated with, endorsed by, or supported by Dimplex, Glen
44
+ > Dimplex Heating & Ventilation (GDHV), or the Glen Dimplex Group. This is an independent
45
+ > project built by **reverse-engineering** the official Dimplex Control Android app and the
46
+ > private cloud API it talks to.
47
+ >
48
+ > There is no public or documented API. Dimplex can change or withdraw that API at any time
49
+ > and without notice, which may break this library. Use it at your own risk.
50
+ >
51
+ > No Dimplex code is included or redistributed. "Dimplex", "Quantum", "QRAD" and related
52
+ > marks belong to their respective owners and are used here only to describe compatibility.
53
+
43
54
  ## What does this do?
44
55
 
45
56
  `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
57
 
47
58
  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
59
 
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
60
  ## Contents
52
61
 
53
62
  - [Features](#features)
@@ -94,9 +103,12 @@ pip install .
94
103
  ```bash
95
104
  git clone https://github.com/KRoperUK/dimplex-controller-py.git
96
105
  cd dimplex-controller-py
97
- pip install -e ".[dev]"
106
+ uv sync
98
107
  ```
99
108
 
109
+ `uv sync` creates `.venv`, installs the library in editable mode, and installs the
110
+ dev dependency group from `uv.lock`.
111
+
100
112
  > **Requires:** Python 3.10 or later.
101
113
 
102
114
  ## Quick start
@@ -186,8 +198,9 @@ await client.set_open_window_detection(hub_id, [appliance_id], True)
186
198
  # Timed Boost (ApplianceModes=2, Time = minutes)
187
199
  await client.set_boost(hub_id, [appliance_id], temperature=25.0, duration_minutes=60)
188
200
 
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.
201
+ # Away until a given moment (ApplianceModes=4). Away is a settable 7–18 °C
202
+ # setback and defaults to the 7 °C anti-freeze floor. A higher temperature is
203
+ # clamped to 18 °C with a warning, matching what the cloud does to it anyway.
191
204
  from datetime import datetime, timedelta, timezone
192
205
 
193
206
  await client.set_away(
@@ -292,7 +305,7 @@ Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_toke
292
305
 
293
306
  - **`DimplexError`** — Base exception.
294
307
  - **`DimplexAuthError`** — Authentication or token errors.
295
- - **`DimplexApiError`** — API returned a non-success status. Contains `status` and `message`.
308
+ - **`DimplexApiError`** — API returned a non-success status. Contains `status`, `message`, `transient` (`True` for a 5xx/429 raised after retries were exhausted, so a caller can back off rather than surface it) and an optional `code`.
296
309
  - **`DimplexConnectionError`** — Network-level failures.
297
310
 
298
311
  ## Troubleshooting
@@ -389,4 +402,3 @@ Key points:
389
402
  ## Changelog
390
403
 
391
404
  See [CHANGELOG.md](CHANGELOG.md) for version history.
392
-
@@ -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)
@@ -67,9 +77,12 @@ pip install .
67
77
  ```bash
68
78
  git clone https://github.com/KRoperUK/dimplex-controller-py.git
69
79
  cd dimplex-controller-py
70
- pip install -e ".[dev]"
80
+ uv sync
71
81
  ```
72
82
 
83
+ `uv sync` creates `.venv`, installs the library in editable mode, and installs the
84
+ dev dependency group from `uv.lock`.
85
+
73
86
  > **Requires:** Python 3.10 or later.
74
87
 
75
88
  ## Quick start
@@ -159,8 +172,9 @@ await client.set_open_window_detection(hub_id, [appliance_id], True)
159
172
  # Timed Boost (ApplianceModes=2, Time = minutes)
160
173
  await client.set_boost(hub_id, [appliance_id], temperature=25.0, duration_minutes=60)
161
174
 
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.
175
+ # Away until a given moment (ApplianceModes=4). Away is a settable 7–18 °C
176
+ # setback and defaults to the 7 °C anti-freeze floor. A higher temperature is
177
+ # clamped to 18 °C with a warning, matching what the cloud does to it anyway.
164
178
  from datetime import datetime, timedelta, timezone
165
179
 
166
180
  await client.set_away(
@@ -265,7 +279,7 @@ Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_toke
265
279
 
266
280
  - **`DimplexError`** — Base exception.
267
281
  - **`DimplexAuthError`** — Authentication or token errors.
268
- - **`DimplexApiError`** — API returned a non-success status. Contains `status` and `message`.
282
+ - **`DimplexApiError`** — API returned a non-success status. Contains `status`, `message`, `transient` (`True` for a 5xx/429 raised after retries were exhausted, so a caller can back off rather than surface it) and an optional `code`.
269
283
  - **`DimplexConnectionError`** — Network-level failures.
270
284
 
271
285
  ## Troubleshooting
@@ -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
 
@@ -252,12 +274,14 @@ class DimplexControl:
252
274
  attempts - 1,
253
275
  delay,
254
276
  )
255
- last_error = DimplexApiError(resp.status, text)
277
+ last_error = DimplexApiError(resp.status, text, transient=resp.status in _RETRYABLE_STATUS)
256
278
  await asyncio.sleep(delay)
257
279
  continue
258
280
 
259
281
  _LOGGER.error("API request failed: %s - %s", resp.status, text)
260
- raise DimplexApiError(resp.status, text)
282
+ # A 5xx/429 that reaches here was retried and still failed, so it
283
+ # is transient (back off); a 4xx is a hard failure (surface it).
284
+ raise DimplexApiError(resp.status, text, transient=resp.status in _RETRYABLE_STATUS)
261
285
  except (aiohttp.ClientError, asyncio.TimeoutError) as e:
262
286
  if allow_retry and attempt + 1 < attempts:
263
287
  delay = self._backoff_seconds(attempt)
@@ -325,7 +349,7 @@ class DimplexControl:
325
349
  """Get details for a specific zone."""
326
350
  payload = {"HubId": hub_id, "ZoneId": zone_id}
327
351
  data = await self._request("POST", "/Zones/GetZone", json=payload)
328
- return Zone.model_validate(data) # type: ignore[no-any-return]
352
+ return Zone.model_validate(data)
329
353
 
330
354
  async def get_appliance_overview(self, hub_id: str, appliance_ids: list[str]) -> list[ApplianceStatus]:
331
355
  """Get status overview for specific appliances.
@@ -351,7 +375,7 @@ class DimplexControl:
351
375
  async def get_user_context(self) -> UserContext:
352
376
  """Get user profile/context."""
353
377
  data = await self._request("GET", "/Identity/GetUserContext")
354
- return UserContext.model_validate(data) # type: ignore[no-any-return]
378
+ return UserContext.model_validate(data)
355
379
 
356
380
  async def get_product_models(self) -> list[ProductModel]:
357
381
  """Return the cloud product catalogue (models + provisioning metadata).
@@ -371,7 +395,7 @@ class DimplexControl:
371
395
  "TimerMode": 0, # Required field in request; value ignored on read
372
396
  }
373
397
  data = await self._request("POST", "/RemoteControl/GetTimerModeDetailsForAppliance", json=payload)
374
- return TimerModeSettings.model_validate(data) # type: ignore[no-any-return]
398
+ return TimerModeSettings.model_validate(data)
375
399
 
376
400
  async def get_schedule(self, hub_id: str, appliance_id: str) -> TimerModeSettings:
377
401
  """Return the current timer mode + periods (alias of :meth:`get_appliance_features`)."""
@@ -591,15 +615,26 @@ class DimplexControl:
591
615
  """Enable or disable Away mode.
592
616
 
593
617
  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.
618
+ Away is a *settable* setback: the app offers 7–18 °C
619
+ (:data:`~dimplex_controller.AWAY_TEMP_MIN` / :data:`~dimplex_controller.AWAY_TEMP_MAX`)
620
+ and defaults to the 7 °C anti-freeze floor, so a low ``temperature`` is
621
+ the normal case rather than a bug.
597
622
 
598
623
  ``until`` is the "away until" datetime the app sends in ``Date``. Pass a
599
624
  :class:`~datetime.datetime` or an ISO-8601 string. ``number_of_days`` is
600
625
  retained for backwards compatibility and, when ``until`` is omitted, is
601
626
  converted into an equivalent ``Date``.
602
627
 
628
+ When engaging Away, ``temperature`` is clamped into the Away range and a
629
+ warning logged if it had to be: the cloud silently reduces a higher
630
+ value, so clamping locally makes the outcome the caller actually gets
631
+ visible rather than surprising (dimplex-controller-py#98). The clamp is
632
+ skipped when clearing, where the temperature is inert.
633
+
634
+ .. versionchanged:: 0.13.1
635
+ ``temperature`` is clamped to ``AWAY_TEMP_MIN``..``AWAY_TEMP_MAX``
636
+ when ``enable`` is true.
637
+
603
638
  .. versionchanged:: 0.13.0
604
639
  Previously sent ``ApplianceModes=32``, which is **FrostProtect** —
605
640
  the appliance was pinned to the fixed 7 °C frost setpoint and the
@@ -607,6 +642,8 @@ class DimplexControl:
607
642
  Away duration now travels in ``Date`` as the app does, not only in
608
643
  ``NumberOfDays``.
609
644
  """
645
+ if enable:
646
+ temperature = _clamp_away_temperature(temperature)
610
647
  days = int(number_of_days)
611
648
  if until is None and days > 0:
612
649
  until = datetime.now(timezone.utc) + timedelta(days=days)
@@ -876,7 +913,7 @@ class DimplexControl:
876
913
  "/RemoteControl/ApiGetTimerModeDetailsForHeatPumpHwcAppliance",
877
914
  json=payload,
878
915
  )
879
- return TimerModeSettings.model_validate(data) # type: ignore[no-any-return]
916
+ return TimerModeSettings.model_validate(data)
880
917
 
881
918
  async def set_heat_pump_hot_water_schedule(self, settings: TimerModeSettings) -> TimerModeSettings:
882
919
  """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.
@@ -79,11 +79,31 @@ class DimplexAuthTransientError(DimplexAuthError):
79
79
 
80
80
 
81
81
  class DimplexApiError(DimplexError):
82
- """Exception for API errors."""
82
+ """Exception for API errors.
83
83
 
84
- def __init__(self, status: int, message: str):
84
+ Attributes:
85
+ status: HTTP status returned by the API.
86
+ message: Short server message (never a full token).
87
+ transient: ``True`` when the failure may succeed on retry — a 5xx or 429
88
+ raised only after ``client.py`` exhausted its internal retries. ``False``
89
+ for a hard 4xx (404/400/403). Callers (e.g. the Home Assistant
90
+ coordinator) back off and keep entities available on ``transient``, and
91
+ surface the error otherwise.
92
+ code: Optional stable, machine-readable identifier.
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ status: int,
98
+ message: str,
99
+ *,
100
+ transient: bool = False,
101
+ code: str | None = None,
102
+ ):
85
103
  self.status = status
86
104
  self.message = message
105
+ self.transient = transient
106
+ self.code = code
87
107
  super().__init__(f"API Error {status}: {message}")
88
108
 
89
109
 
@@ -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
 
@@ -287,6 +287,14 @@ class ApplianceStatus(BaseModel):
287
287
  ErrorCode: str | None = None
288
288
  WarningCode: str | None = None
289
289
 
290
+ @field_validator("ErrorCode", "WarningCode", mode="before")
291
+ @classmethod
292
+ def _coerce_code(cls, value: Any) -> Any:
293
+ # GetApplianceOverview sends these as integers (0 when healthy).
294
+ if isinstance(value, int) and not isinstance(value, bool):
295
+ return str(value)
296
+ return value
297
+
290
298
  @property
291
299
  def mode_flags(self) -> ApplianceModeFlag:
292
300
  """Return ``ApplianceModes`` as a typed flag set."""
@@ -425,7 +433,7 @@ class TsiEnergyReport(BaseModel):
425
433
  """
426
434
 
427
435
  HubId: str
428
- ApplianceTelemetryData: dict[str, list] = Field(default_factory=dict)
436
+ ApplianceTelemetryData: dict[str, list[Any]] = Field(default_factory=dict)
429
437
 
430
438
 
431
439
  # `TsiReportType` integer values understood by the API. We do not know the
@@ -117,6 +117,15 @@ def _coerce_timestamp(raw: Any) -> datetime | None:
117
117
  return raw
118
118
  if isinstance(raw, int | float):
119
119
  ts = float(raw)
120
+ # Seconds-vs-milliseconds disambiguation is magnitude-only, which is safe for
121
+ # the data this parses: a Unix *seconds* epoch does not reach 1e12 until the
122
+ # year ~33658, and the cloud emits second-resolution `TS` values, so anything
123
+ # above the threshold is a millisecond epoch. The converse edge is not
124
+ # defended — a millisecond epoch before 2001-09-09 (< 1e12) would be misread
125
+ # as seconds — because the cloud has never produced one. A value just below
126
+ # 1e12 read as seconds lands past datetime's representable range and is
127
+ # dropped by the guard below rather than mis-parsed. See
128
+ # dimplex-controller-py#121 for why this is intentional rather than a gap.
120
129
  if ts > 1e12:
121
130
  ts = ts / 1000.0
122
131
  try:
@@ -276,6 +285,11 @@ def summarise_energy(
276
285
  ``points`` may be raw cloud payloads or already-parsed
277
286
  :data:`TelemetryPoint` tuples. When raw, they are passed through
278
287
  :func:`parse_telemetry_points` first.
288
+
289
+ Points with no parseable timestamp (``ts is None``) are excluded from the
290
+ total in **every** mode. They cannot be placed on a calendar day, so
291
+ counting them only in ``lifetime`` would make the cumulative total disagree
292
+ with the sum of the per-day totals (dimplex-controller-py#120).
279
293
  """
280
294
  parsed: list[TelemetryPoint]
281
295
  if (
@@ -285,7 +299,7 @@ def summarise_energy(
285
299
  and isinstance(points[0], tuple)
286
300
  and len(points[0]) == 2
287
301
  ):
288
- parsed = list(points) # type: ignore[arg-type]
302
+ parsed = list(points)
289
303
  else:
290
304
  parsed = parse_telemetry_points(points, value_keys=value_keys)
291
305
 
@@ -306,8 +320,16 @@ def summarise_energy(
306
320
  mode="daily",
307
321
  )
308
322
 
309
- # lifetime — include every parseable point; window — optional start/end filter
310
- selected = filter_telemetry_points(parsed, start=start, end=end, tz=tz) if mode == "window" else list(parsed)
323
+ # lifetime / window — totals must reconcile with the per-day totals, so a point
324
+ # with no parseable timestamp is dropped from both. A ``ts is None`` point cannot
325
+ # be placed on any calendar day (``daily``/``window`` already drop it via
326
+ # ``filter_telemetry_points``), so counting it only in the lifetime total made the
327
+ # cumulative meter disagree with the sum of its days — silent drift into the HA
328
+ # Energy Dashboard's TOTAL_INCREASING sensor (dimplex-controller-py#120).
329
+ if mode == "window":
330
+ selected = filter_telemetry_points(parsed, start=start, end=end, tz=tz)
331
+ else:
332
+ selected = [(ts, value) for ts, value in parsed if ts is not None]
311
333
 
312
334
  timestamps = [ts for ts, _ in selected if ts is not None]
313
335
  total = sum(v for _, v in selected)
@@ -0,0 +1,96 @@
1
+ [project]
2
+ name = "dimplex-controller"
3
+ version = "0.14.0"
4
+ description = "Unofficial async Python client for Dimplex (GDHV IoT) heating appliances — not affiliated with Glen Dimplex"
5
+ license = "MIT"
6
+ license-files = ["LICENSE"]
7
+ readme = "README.md"
8
+ requires-python = ">=3.10,<4.0"
9
+ keywords = [
10
+ "dimplex",
11
+ "heating",
12
+ "control",
13
+ "iot",
14
+ "asyncio",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 5 - Production/Stable",
18
+ "Intended Audience :: Developers",
19
+ "Natural Language :: English",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Programming Language :: Python :: 3.14",
27
+ "Topic :: Home Automation",
28
+ ]
29
+ dependencies = [
30
+ "aiohttp>=3.14.3,<4",
31
+ "pydantic>=2.13.5,<3",
32
+ ]
33
+
34
+ [[project.authors]]
35
+ name = "Kieran Roper"
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/KRoperUK/dimplex-controller-py"
39
+ Repository = "https://github.com/KRoperUK/dimplex-controller-py"
40
+
41
+ [project.scripts]
42
+ dimplex = "dimplex_controller.cli:main"
43
+
44
+ [dependency-groups]
45
+ dev = [
46
+ "pytest>=9.1.1,<10",
47
+ "pytest-asyncio>=1.4.0,<2",
48
+ "pytest-cov>=7.1.0,<8",
49
+ "aresponses>=3.0.0,<4",
50
+ "ruff>=0.16.7,<0.17",
51
+ "pre-commit>=4.6.2,<5",
52
+ "twine>=7.0.0,<8",
53
+ "python-dotenv>=1.2.3,<2",
54
+ "mypy>=2.3.1,<3",
55
+ ]
56
+
57
+ [tool.ruff]
58
+ line-length = 120
59
+ target-version = "py310"
60
+ extend-exclude = ["*.md"]
61
+
62
+ [tool.ruff.lint]
63
+ select = [
64
+ "E",
65
+ "F",
66
+ "I",
67
+ "W",
68
+ "UP",
69
+ "B",
70
+ "SIM",
71
+ ]
72
+ ignore = ["E501"]
73
+
74
+ [tool.mypy]
75
+ python_version = "3.10"
76
+ files = ["dimplex_controller"]
77
+ ignore_missing_imports = true
78
+ strict = true
79
+
80
+ [tool.pytest.ini_options]
81
+ asyncio_mode = "auto"
82
+ testpaths = ["tests"]
83
+
84
+ [tool.coverage.run]
85
+ source = ["dimplex_controller"]
86
+
87
+ [tool.coverage.report]
88
+ show_missing = true
89
+ fail_under = 80
90
+
91
+ [tool.uv.build-backend]
92
+ module-root = ""
93
+
94
+ [build-system]
95
+ requires = ["uv_build>=0.11.19,<0.13.0"]
96
+ build-backend = "uv_build"
@@ -0,0 +1,106 @@
1
+ [project]
2
+ name = "dimplex-controller"
3
+ version = "0.14.0"
4
+ description = "Unofficial async Python client for Dimplex (GDHV IoT) heating appliances — not affiliated with Glen Dimplex"
5
+ authors = [{ name = "Kieran Roper" }]
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ readme = "README.md"
9
+ requires-python = ">=3.10,<4.0"
10
+ keywords = ["dimplex", "heating", "control", "iot", "asyncio"]
11
+ classifiers = [
12
+ "Development Status :: 5 - Production/Stable",
13
+ "Intended Audience :: Developers",
14
+ "Natural Language :: English",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
22
+ "Topic :: Home Automation",
23
+ ]
24
+ # Lower bounds are the versions this release was developed and tested against;
25
+ # the upper bounds keep the previous caret semantics (a caret range is
26
+ # `>=X,<next-major`), so consumers gain no surprise from the migration.
27
+ dependencies = [
28
+ "aiohttp>=3.14.3,<4",
29
+ "pydantic>=2.13.5,<3",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/KRoperUK/dimplex-controller-py"
34
+ Repository = "https://github.com/KRoperUK/dimplex-controller-py"
35
+
36
+ [project.scripts]
37
+ dimplex = "dimplex_controller.cli:main"
38
+
39
+ # PEP 735 dependency groups: not installed into a consumer's environment, only
40
+ # into a development one.
41
+ [dependency-groups]
42
+ dev = [
43
+ "pytest>=9.1.1,<10",
44
+ "pytest-asyncio>=1.4.0,<2",
45
+ "pytest-cov>=7.1.0,<8",
46
+ "aresponses>=3.0.0,<4",
47
+ "ruff>=0.16.7,<0.17",
48
+ "pre-commit>=4.6.2,<5",
49
+ "twine>=7.0.0,<8",
50
+ "python-dotenv>=1.2.3,<2",
51
+ "mypy>=2.3.1,<3",
52
+ ]
53
+
54
+ [tool.ruff]
55
+ line-length = 120
56
+ target-version = "py310"
57
+ # Python inside Markdown is not formatted here. The code blocks in README.md and
58
+ # docs/ carry deliberate column alignment (see the temperature-constant table in
59
+ # docs/api-reference.md) and ruff's formatter normalises comment spacing, so
60
+ # formatting them would trade readable docs for tool uniformity. This also keeps a
61
+ # bare `ruff format .` in step with the pre-commit hooks, which are python/pyi only.
62
+ extend-exclude = ["*.md"]
63
+
64
+ [tool.ruff.lint]
65
+ select = ["E", "F", "I", "W", "UP", "B", "SIM"]
66
+ # E501 (line length) is handled by the formatter; long strings/comments are fine.
67
+ ignore = ["E501"]
68
+
69
+ [tool.mypy]
70
+ python_version = "3.10"
71
+ files = ["dimplex_controller"]
72
+ ignore_missing_imports = true
73
+ # `strict` rather than the four flags this used to carry (disallow_untyped_defs,
74
+ # warn_return_any, warn_unused_configs, warn_redundant_casts). The library is the
75
+ # layer the Home Assistant integration trusts for models, capabilities and error
76
+ # types, and that consumer runs strict mypy over its own code — so a library
77
+ # checked more loosely could hand it types that do not hold
78
+ # (dimplex-controller-hass#202).
79
+ #
80
+ # Raising it surfaced seven stale `# type: ignore` comments, all of which mypy
81
+ # reported as unused, plus one missing generic argument. No real type errors.
82
+ strict = true
83
+
84
+ [tool.pytest.ini_options]
85
+ # All async tests use asyncio; "auto" means new tests don't each need the
86
+ # @pytest.mark.asyncio decorator (existing markers remain valid).
87
+ asyncio_mode = "auto"
88
+ testpaths = ["tests"]
89
+
90
+ [tool.coverage.run]
91
+ source = ["dimplex_controller"]
92
+
93
+ [tool.coverage.report]
94
+ show_missing = true
95
+ # Guardrail so coverage can't silently regress. Raise as coverage improves.
96
+ fail_under = 80
97
+
98
+ [build-system]
99
+ requires = ["uv_build>=0.11.19,<0.13.0"]
100
+ build-backend = "uv_build"
101
+
102
+ [tool.uv.build-backend]
103
+ # The package is at the repository root, not under src/, which is what uv_build
104
+ # assumes by default. The module name is inferred from the project name
105
+ # (dimplex-controller -> dimplex_controller), so it needs no override.
106
+ module-root = ""
@@ -1,80 +0,0 @@
1
- [tool.poetry]
2
- name = "dimplex-controller"
3
- version = "0.13.0"
4
- description = "Python client for Dimplex heating controllers (GDHV IoT)"
5
- authors = ["Kieran Roper"]
6
- license = "MIT"
7
- readme = "README.md"
8
- homepage = "https://github.com/KRoperUK/dimplex-controller-py"
9
- repository = "https://github.com/KRoperUK/dimplex-controller-py"
10
- keywords = ["dimplex", "heating", "control", "iot", "asyncio"]
11
- classifiers = [
12
- "Development Status :: 5 - Production/Stable",
13
- "Intended Audience :: Developers",
14
- "License :: OSI Approved :: MIT License",
15
- "Natural Language :: English",
16
- "Operating System :: OS Independent",
17
- "Programming Language :: Python :: 3",
18
- "Programming Language :: Python :: 3.10",
19
- "Programming Language :: Python :: 3.11",
20
- "Programming Language :: Python :: 3.12",
21
- "Programming Language :: Python :: 3.13",
22
- "Topic :: Home Automation",
23
- ]
24
- packages = [{ include = "dimplex_controller" }]
25
-
26
- [tool.poetry.scripts]
27
- dimplex = "dimplex_controller.cli:main"
28
-
29
- [tool.poetry.dependencies]
30
- python = "^3.10"
31
- aiohttp = "^3.9.0"
32
- pydantic = "^2.0.0"
33
-
34
- [tool.poetry.group.dev.dependencies]
35
- pytest = "^8.0.0"
36
- pytest-asyncio = "^1.0.0"
37
- pytest-cov = "^6.0.0"
38
- aresponses = "^3.0.0"
39
- ruff = "^0.15.0"
40
- pre-commit = "^3.6.0"
41
- twine = "^6.2.0"
42
- python-dotenv = "^1.2.1"
43
- mypy = "^1.0.0"
44
-
45
- [tool.ruff]
46
- line-length = 120
47
- target-version = "py310"
48
-
49
- [tool.ruff.lint]
50
- select = ["E", "F", "I", "W", "UP", "B", "SIM"]
51
- # E501 (line length) is handled by the formatter; long strings/comments are fine.
52
- ignore = ["E501"]
53
-
54
- [tool.mypy]
55
- python_version = "3.10"
56
- files = ["dimplex_controller"]
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
63
-
64
- [tool.pytest.ini_options]
65
- # All async tests use asyncio; "auto" means new tests don't each need the
66
- # @pytest.mark.asyncio decorator (existing markers remain valid).
67
- asyncio_mode = "auto"
68
- testpaths = ["tests"]
69
-
70
- [tool.coverage.run]
71
- source = ["dimplex_controller"]
72
-
73
- [tool.coverage.report]
74
- show_missing = true
75
- # Guardrail so coverage can't silently regress. Raise as coverage improves.
76
- fail_under = 80
77
-
78
- [build-system]
79
- requires = ["poetry-core"]
80
- build-backend = "poetry.core.masonry.api"