dimplex-controller 0.13.1__tar.gz → 0.15.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.1
3
+ Version: 0.15.0
4
4
  Summary: Unofficial async Python client for Dimplex (GDHV IoT) heating appliances — not affiliated with Glen Dimplex
5
- License: MIT
6
- License-File: LICENSE
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.9.0,<4
21
+ Requires-Dist: pydantic>=2.0.0,<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
@@ -104,9 +103,12 @@ pip install .
104
103
  ```bash
105
104
  git clone https://github.com/KRoperUK/dimplex-controller-py.git
106
105
  cd dimplex-controller-py
107
- pip install -e ".[dev]"
106
+ uv sync
108
107
  ```
109
108
 
109
+ `uv sync` creates `.venv`, installs the library in editable mode, and installs the
110
+ dev dependency group from `uv.lock`.
111
+
110
112
  > **Requires:** Python 3.10 or later.
111
113
 
112
114
  ## Quick start
@@ -283,7 +285,9 @@ Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_toke
283
285
  | `set_setback_temperature(...)` | Write the setback temperature (untested). |
284
286
  | `set_eco_start(hub_id, appliance_ids, enable)` | Toggle EcoStart. |
285
287
  | `set_open_window_detection(hub_id, appliance_ids, enable)` | Toggle Open Window Detection. |
286
- | `set_hot_water_*(...)` / `*_heat_pump_hot_water_schedule(...)` | Hot-water cylinder surface (untested — no hardware). |
288
+ | `set_hot_water_holiday(hub_id, appliance_ids, until, *, enable, heat_pump)` | Cylinder Holiday on/off (validated on an ASHW cylinder). |
289
+ | `get_heat_pump_hot_water_schedule(...)` | ASHW cylinder schedule as `HeatPumpHwcSchedule` (validated). Writing it answers 403. |
290
+ | `set_hot_water_*(...)` | Remaining cylinder controls (untested — no hardware). |
287
291
  | `get_tsi_energy_report(hub_id, ...)` | Returns `TsiEnergyReport`. |
288
292
  | `capabilities_for(appliance, *, status, product)` | Derive an `ApplianceCapabilities` matrix. |
289
293
  | `export_tokens()` / `apply_tokens(bundle)` | Token persistence helpers. |
@@ -303,7 +307,7 @@ Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_toke
303
307
 
304
308
  - **`DimplexError`** — Base exception.
305
309
  - **`DimplexAuthError`** — Authentication or token errors.
306
- - **`DimplexApiError`** — API returned a non-success status. Contains `status` and `message`.
310
+ - **`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`.
307
311
  - **`DimplexConnectionError`** — Network-level failures.
308
312
 
309
313
  ## Troubleshooting
@@ -400,4 +404,3 @@ Key points:
400
404
  ## Changelog
401
405
 
402
406
  See [CHANGELOG.md](CHANGELOG.md) for version history.
403
-
@@ -77,9 +77,12 @@ pip install .
77
77
  ```bash
78
78
  git clone https://github.com/KRoperUK/dimplex-controller-py.git
79
79
  cd dimplex-controller-py
80
- pip install -e ".[dev]"
80
+ uv sync
81
81
  ```
82
82
 
83
+ `uv sync` creates `.venv`, installs the library in editable mode, and installs the
84
+ dev dependency group from `uv.lock`.
85
+
83
86
  > **Requires:** Python 3.10 or later.
84
87
 
85
88
  ## Quick start
@@ -256,7 +259,9 @@ Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_toke
256
259
  | `set_setback_temperature(...)` | Write the setback temperature (untested). |
257
260
  | `set_eco_start(hub_id, appliance_ids, enable)` | Toggle EcoStart. |
258
261
  | `set_open_window_detection(hub_id, appliance_ids, enable)` | Toggle Open Window Detection. |
259
- | `set_hot_water_*(...)` / `*_heat_pump_hot_water_schedule(...)` | Hot-water cylinder surface (untested — no hardware). |
262
+ | `set_hot_water_holiday(hub_id, appliance_ids, until, *, enable, heat_pump)` | Cylinder Holiday on/off (validated on an ASHW cylinder). |
263
+ | `get_heat_pump_hot_water_schedule(...)` | ASHW cylinder schedule as `HeatPumpHwcSchedule` (validated). Writing it answers 403. |
264
+ | `set_hot_water_*(...)` | Remaining cylinder controls (untested — no hardware). |
260
265
  | `get_tsi_energy_report(hub_id, ...)` | Returns `TsiEnergyReport`. |
261
266
  | `capabilities_for(appliance, *, status, product)` | Derive an `ApplianceCapabilities` matrix. |
262
267
  | `export_tokens()` / `apply_tokens(bundle)` | Token persistence helpers. |
@@ -276,7 +281,7 @@ Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_toke
276
281
 
277
282
  - **`DimplexError`** — Base exception.
278
283
  - **`DimplexAuthError`** — Authentication or token errors.
279
- - **`DimplexApiError`** — API returned a non-success status. Contains `status` and `message`.
284
+ - **`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`.
280
285
  - **`DimplexConnectionError`** — Network-level failures.
281
286
 
282
287
  ## Troubleshooting
@@ -45,6 +45,8 @@ from .models import (
45
45
  ApplianceModeStatus,
46
46
  ApplianceStatus,
47
47
  AutomaticProvisioning,
48
+ HeatPumpHwcPeriod,
49
+ HeatPumpHwcSchedule,
48
50
  Hub,
49
51
  HygieneFrequency,
50
52
  ProductModel,
@@ -62,6 +64,7 @@ from .telemetry import (
62
64
  EnergySummary,
63
65
  filter_telemetry_points,
64
66
  parse_telemetry_points,
67
+ reported_energy,
65
68
  summarise_energy,
66
69
  )
67
70
 
@@ -83,6 +86,8 @@ __all__ = [
83
86
  "SetbackStatus",
84
87
  "TimerMode",
85
88
  "TimerModeSettings",
89
+ "HeatPumpHwcPeriod",
90
+ "HeatPumpHwcSchedule",
86
91
  "TimerPeriod",
87
92
  "AutomaticProvisioning",
88
93
  "ProductModel",
@@ -98,6 +103,7 @@ __all__ = [
98
103
  "NULL_DATETIME",
99
104
  "parse_telemetry_points",
100
105
  "filter_telemetry_points",
106
+ "reported_energy",
101
107
  "summarise_energy",
102
108
  "EnergySummary",
103
109
  "VALUE_KEY_T1",
@@ -24,6 +24,14 @@ class ApplianceCapabilities:
24
24
  Flags are best-effort: when status/product metadata is incomplete the
25
25
  library prefers enabling known cloud control paths (boost/away/OWD) so
26
26
  users are not locked out of working RPCs.
27
+
28
+ One flag is weaker than the rest and must not be used as a gate:
29
+ :attr:`energy_meter` is a *family hint* derived from the nameplate, so it is
30
+ true for essentially every appliance with a rated power. When the question is
31
+ "has this appliance ever reported energy?", ask the telemetry —
32
+ :func:`dimplex_controller.reported_energy`, or
33
+ :func:`dimplex_controller.summarise_energy` when the totals are wanted too
34
+ (dimplex-controller-py#113).
27
35
  """
28
36
 
29
37
  boost: bool = True
@@ -39,6 +47,7 @@ class ApplianceCapabilities:
39
47
  # setpoint path. Preferred over rewriting the schedule via SetTimerMode,
40
48
  # which Quantum rejects with HTTP 403.
41
49
  setpoint_write: bool = True
50
+ # Family hint, not proof of metering — see the class docstring before gating on it.
42
51
  energy_meter: bool = False
43
52
  storage: bool = False
44
53
  hot_water: bool = False
@@ -162,15 +171,23 @@ def capabilities_for(
162
171
  heat_pump = True
163
172
  hot_water = True
164
173
 
165
- boost = True
166
- away = True
174
+ if status is not None and status.AvailableHotWater is not None:
175
+ hot_water = True
176
+
177
+ # A cylinder reports its *water* target in ActiveSetPointTemperature (45 °C
178
+ # on a live ASHW cylinder), so only a room temperature makes it room climate.
179
+ # Without one, the room-heater features default to off — a live cylinder
180
+ # sends null for every one of them — unless its status reports them.
181
+ climate = not hot_water or (status is not None and status.RoomTemperature is not None)
182
+
183
+ boost = climate
184
+ away = climate
167
185
  advance = True
168
- open_window = True
169
- eco_start = True
170
- setback_read = True
171
- frost = True
186
+ open_window = climate
187
+ eco_start = climate
188
+ setback_read = climate
189
+ frost = climate
172
190
  timer = True
173
- climate = not hot_water # cylinder-only appliances are not room climate
174
191
 
175
192
  if status is not None:
176
193
  if status.BoostDuration is not None or status.BoostTemperature is not None:
@@ -183,10 +200,6 @@ def capabilities_for(
183
200
  eco_start = True
184
201
  if status.SetbackEnabled is not None or status.SetbackTemperature is not None:
185
202
  setback_read = True
186
- if status.AvailableHotWater is not None:
187
- hot_water = True
188
- if status.RoomTemperature is not None or status.ActiveSetPointTemperature is not None:
189
- climate = True
190
203
 
191
204
  # Advance only means something for a scheduled room heater; a cylinder has
192
205
  # no "next comfort period" to jump to.
@@ -200,10 +213,11 @@ def capabilities_for(
200
213
  open_window=open_window,
201
214
  eco_start=eco_start,
202
215
  setback_read=setback_read,
203
- setback_write=True,
216
+ setback_write=climate,
204
217
  frost=frost,
205
218
  timer=timer,
206
- setpoint_write=True,
219
+ # The room setpoint endpoint; cylinders have their own (Set*TemperatureHwc).
220
+ setpoint_write=climate,
207
221
  energy_meter=energy_meter,
208
222
  storage=storage,
209
223
  hot_water=hot_water,
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import json
5
5
  import logging
6
+ import math
6
7
  import random
7
8
  from datetime import datetime, timedelta, timezone
8
9
  from typing import Any
@@ -36,6 +37,7 @@ from .models import (
36
37
  ApplianceModeSettings,
37
38
  ApplianceModeStatus,
38
39
  ApplianceStatus,
40
+ HeatPumpHwcSchedule,
39
41
  Hub,
40
42
  HygieneFrequency,
41
43
  ProductModel,
@@ -274,12 +276,14 @@ class DimplexControl:
274
276
  attempts - 1,
275
277
  delay,
276
278
  )
277
- last_error = DimplexApiError(resp.status, text)
279
+ last_error = DimplexApiError(resp.status, text, transient=resp.status in _RETRYABLE_STATUS)
278
280
  await asyncio.sleep(delay)
279
281
  continue
280
282
 
281
283
  _LOGGER.error("API request failed: %s - %s", resp.status, text)
282
- raise DimplexApiError(resp.status, text)
284
+ # A 5xx/429 that reaches here was retried and still failed, so it
285
+ # is transient (back off); a 4xx is a hard failure (surface it).
286
+ raise DimplexApiError(resp.status, text, transient=resp.status in _RETRYABLE_STATUS)
283
287
  except (aiohttp.ClientError, asyncio.TimeoutError) as e:
284
288
  if allow_retry and attempt + 1 < attempts:
285
289
  delay = self._backoff_seconds(attempt)
@@ -838,6 +842,42 @@ class DimplexControl:
838
842
  endpoint=endpoint,
839
843
  )
840
844
 
845
+ async def set_hot_water_holiday(
846
+ self,
847
+ hub_id: str,
848
+ appliance_ids: list[str],
849
+ until: datetime | None = None,
850
+ *,
851
+ enable: bool = True,
852
+ heat_pump: bool = False,
853
+ ) -> None:
854
+ """Put a cylinder on Holiday (standby) until ``until``, or clear it.
855
+
856
+ Verified on a live ASHW cylinder (EDL200UK-630RF) with ``heat_pump=True``:
857
+ engaging moved ``ApplianceModes`` 8192 → 8200 and set
858
+ ``HolidayNumOfDays``, heating stopped, and clearing restored 8192. The
859
+ cylinder counts holidays in whole days, so ``until`` is also sent as a
860
+ rounded-up day count; a caller that wants a time of day has to clear
861
+ Holiday itself at that time.
862
+ """
863
+ days = 0
864
+ if enable:
865
+ if until is None:
866
+ raise ValueError("a holiday needs an end time")
867
+ end = until if until.tzinfo is not None else until.replace(tzinfo=timezone.utc)
868
+ seconds = (end - datetime.now(timezone.utc)).total_seconds()
869
+ days = max(1, math.ceil(seconds / 86400))
870
+ endpoint = "/RemoteControl/SetApplianceModeHeatPumpHwc" if heat_pump else "/RemoteControl/SetApplianceModeHwc"
871
+ await self.set_mode_flag(
872
+ hub_id,
873
+ appliance_ids,
874
+ ApplianceModeFlag.HOLIDAY,
875
+ enable=enable,
876
+ until=until,
877
+ number_of_days=days,
878
+ endpoint=endpoint,
879
+ )
880
+
841
881
  async def set_hot_water_boost_temperature(
842
882
  self, hub_id: str, appliance_ids: list[str], temperature: float, *, enable: bool = True
843
883
  ) -> None:
@@ -900,10 +940,12 @@ class DimplexControl:
900
940
  endpoint=endpoint,
901
941
  )
902
942
 
903
- async def get_heat_pump_hot_water_schedule(self, hub_id: str, appliance_id: str) -> TimerModeSettings:
904
- """Read an ASHW cylinder's schedule.
943
+ async def get_heat_pump_hot_water_schedule(self, hub_id: str, appliance_id: str) -> HeatPumpHwcSchedule:
944
+ """Read an ASHW cylinder's weekly schedule.
905
945
 
906
- .. warning:: Untested — APK-confirmed only.
946
+ Verified on a live ASHW cylinder (EDL200UK-630RF). The reply does not fit
947
+ :class:`TimerModeSettings` — there is no ``TimerMode`` and each period is
948
+ ``{DayOfWeek, StartTime, Mode}`` — hence :class:`HeatPumpHwcSchedule`.
907
949
  """
908
950
  payload = {"HubId": hub_id, "ApplianceId": appliance_id}
909
951
  data = await self._request(
@@ -911,12 +953,16 @@ class DimplexControl:
911
953
  "/RemoteControl/ApiGetTimerModeDetailsForHeatPumpHwcAppliance",
912
954
  json=payload,
913
955
  )
914
- return TimerModeSettings.model_validate(data)
956
+ return HeatPumpHwcSchedule.model_validate(data)
915
957
 
916
958
  async def set_heat_pump_hot_water_schedule(self, settings: TimerModeSettings) -> TimerModeSettings:
917
959
  """Write an ASHW cylinder's schedule periods.
918
960
 
919
- .. warning:: Untested — APK-confirmed only.
961
+ .. warning:: Does not work yet. On a live ASHW cylinder this endpoint
962
+ answered HTTP 403 to every body tried — wrapped in ``TimerModeSettings``
963
+ or ``Settings``, with or without ``TimerMode``, integer or ``HH:MM:SS``
964
+ times, and app versions up to 3.0.0 — while unwrapped bodies got 400.
965
+ The request the official app sends has not been captured.
920
966
  """
921
967
  payload = {"TimerModeSettings": settings.model_dump(mode="json")}
922
968
  await self._request("POST", "/RemoteControl/UpdateHeatPumpHwcSchedulePeriods", json=payload)
@@ -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
 
@@ -255,6 +255,31 @@ class TimerModeSettings(BaseModel):
255
255
  TimerPeriods: list[TimerPeriod] = Field(default_factory=list)
256
256
 
257
257
 
258
+ class HeatPumpHwcPeriod(BaseModel):
259
+ """One period of an ASHW cylinder's weekly schedule.
260
+
261
+ A live cylinder returns one ``{DayOfWeek, StartTime: 0, Mode: 2}`` per day.
262
+ ``DayOfWeek`` is .NET's (0 = Sunday). ``StartTime`` is an integer, presumed
263
+ minutes after midnight, and what each ``Mode`` value means is not yet known.
264
+ """
265
+
266
+ DayOfWeek: int
267
+ StartTime: int
268
+ Mode: int
269
+
270
+
271
+ class HeatPumpHwcSchedule(BaseModel):
272
+ """``ApiGetTimerModeDetailsForHeatPumpHwcAppliance`` reply.
273
+
274
+ Unlike the room-heater :class:`TimerModeSettings` it has no ``TimerMode``
275
+ and its periods carry a mode instead of an end time and temperature.
276
+ """
277
+
278
+ HubId: str
279
+ ApplianceId: str
280
+ TimerPeriods: list[HeatPumpHwcPeriod] = Field(default_factory=list)
281
+
282
+
258
283
  class UserContext(BaseModel):
259
284
  Id: str
260
285
  EmailAddress: str | None = None
@@ -286,6 +311,29 @@ class ApplianceStatus(BaseModel):
286
311
  LockStatus: int | None = None
287
312
  ErrorCode: str | None = None
288
313
  WarningCode: str | None = None
314
+ # Hot-water cylinder fields, as a live ASHW cylinder (EDL200UK-630RF) sends
315
+ # them. AvailableHotWater above is litres of usable hot water (the app shows
316
+ # it as "336 L Hot water").
317
+ Capacity: int | None = None # tank size, litres
318
+ HeatingUp: bool | None = None
319
+ IsGreenEnergyActive: bool | None = None
320
+ HygieneFrequency: int | None = None # EFrequency, see HygieneFrequency
321
+ HygieneTemperature: float | None = None
322
+ HolidayNumOfDays: int | None = None
323
+ # Present in the same reply, always null on the cylinder seen so far.
324
+ DelayedStartTime: str | None = None
325
+ DsmStatus: int | None = None
326
+ ModifiedPower: float | None = None
327
+ ConnectedChargeProfiles: Any = None
328
+ StandaloneChargeProfiles: Any = None
329
+
330
+ @field_validator("ErrorCode", "WarningCode", mode="before")
331
+ @classmethod
332
+ def _coerce_code(cls, value: Any) -> Any:
333
+ # GetApplianceOverview sends these as integers (0 when healthy).
334
+ if isinstance(value, int) and not isinstance(value, bool):
335
+ return str(value)
336
+ return value
289
337
 
290
338
  @property
291
339
  def mode_flags(self) -> ApplianceModeFlag:
@@ -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:
@@ -213,6 +222,34 @@ def parse_telemetry_points(
213
222
  return out
214
223
 
215
224
 
225
+ def reported_energy(
226
+ points: Any,
227
+ *,
228
+ value_keys: tuple[str, ...] | None = None,
229
+ ) -> bool:
230
+ """Return True when telemetry carries usable points for an appliance.
231
+
232
+ The evidence-based counterpart to
233
+ :attr:`ApplianceCapabilities.energy_meter <.capabilities.ApplianceCapabilities>`,
234
+ which is a family hint derived from the nameplate and is therefore true for
235
+ essentially every appliance — it cannot separate a genuinely metered one from an
236
+ appliance the hub never reports energy for (dimplex-controller-py#113).
237
+
238
+ Pass the appliance's entry from
239
+ :attr:`TsiEnergyReport.ApplianceTelemetryData
240
+ <.models.TsiEnergyReport>` (raw entries are normalised by
241
+ :func:`parse_telemetry_points`, so both raw and already-parsed points are
242
+ accepted). Nothing recognised, an empty list, or a value the parser skips all
243
+ answer ``False`` — which is the question a consumer gating entity creation
244
+ actually has.
245
+
246
+ Only presence is decided here. A caller that also wants the figures should call
247
+ :func:`parse_telemetry_points` or :func:`summarise_energy` once and test the
248
+ result, rather than parse a series twice.
249
+ """
250
+ return bool(parse_telemetry_points(points, value_keys=value_keys))
251
+
252
+
216
253
  def _ensure_aware(ts: datetime, default_tz: tzinfo) -> datetime:
217
254
  """Return ``ts`` with a timezone; naive datetimes are assumed ``default_tz``."""
218
255
  if ts.tzinfo is None:
@@ -276,6 +313,11 @@ def summarise_energy(
276
313
  ``points`` may be raw cloud payloads or already-parsed
277
314
  :data:`TelemetryPoint` tuples. When raw, they are passed through
278
315
  :func:`parse_telemetry_points` first.
316
+
317
+ Points with no parseable timestamp (``ts is None``) are excluded from the
318
+ total in **every** mode. They cannot be placed on a calendar day, so
319
+ counting them only in ``lifetime`` would make the cumulative total disagree
320
+ with the sum of the per-day totals (dimplex-controller-py#120).
279
321
  """
280
322
  parsed: list[TelemetryPoint]
281
323
  if (
@@ -306,8 +348,16 @@ def summarise_energy(
306
348
  mode="daily",
307
349
  )
308
350
 
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)
351
+ # lifetime / window — totals must reconcile with the per-day totals, so a point
352
+ # with no parseable timestamp is dropped from both. A ``ts is None`` point cannot
353
+ # be placed on any calendar day (``daily``/``window`` already drop it via
354
+ # ``filter_telemetry_points``), so counting it only in the lifetime total made the
355
+ # cumulative meter disagree with the sum of its days — silent drift into the HA
356
+ # Energy Dashboard's TOTAL_INCREASING sensor (dimplex-controller-py#120).
357
+ if mode == "window":
358
+ selected = filter_telemetry_points(parsed, start=start, end=end, tz=tz)
359
+ else:
360
+ selected = [(ts, value) for ts, value in parsed if ts is not None]
311
361
 
312
362
  timestamps = [ts for ts, _ in selected if ts is not None]
313
363
  total = sum(v for _, v in selected)
@@ -0,0 +1,96 @@
1
+ [project]
2
+ name = "dimplex-controller"
3
+ version = "0.15.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.9.0,<4",
31
+ "pydantic>=2.0.0,<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,115 @@
1
+ [project]
2
+ name = "dimplex-controller"
3
+ version = "0.15.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
+ # The upper bounds keep the previous caret semantics (a caret range is
25
+ # `>=X,<next-major`), so consumers gain no surprise from the uv migration.
26
+ dependencies = [
27
+ # Lower bounds are what the code needs, not what uv.lock happens to resolve.
28
+ # They were raised to the locked versions when the project migrated to uv and
29
+ # bumped everything in one pass (#110), which made this library uninstallable
30
+ # alongside Home Assistant: the integration's test stack pins pydantic 2.13.4
31
+ # while this demanded >=2.13.5, so `dimplex-controller>=0.14.0` resolved to
32
+ # nothing and the HA integration could not adopt the 0.14.0 ErrorCode/WarningCode
33
+ # fix (dimplex-controller-hass#256).
34
+ #
35
+ # Nothing here needs either new API: plain ``BaseModel``, ``field_validator``,
36
+ # ``ConfigDict`` and an aiohttp ``ClientSession`` are all 2.0/3.9-era.
37
+ "aiohttp>=3.9.0,<4",
38
+ "pydantic>=2.0.0,<3",
39
+ ]
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/KRoperUK/dimplex-controller-py"
43
+ Repository = "https://github.com/KRoperUK/dimplex-controller-py"
44
+
45
+ [project.scripts]
46
+ dimplex = "dimplex_controller.cli:main"
47
+
48
+ # PEP 735 dependency groups: not installed into a consumer's environment, only
49
+ # into a development one.
50
+ [dependency-groups]
51
+ dev = [
52
+ "pytest>=9.1.1,<10",
53
+ "pytest-asyncio>=1.4.0,<2",
54
+ "pytest-cov>=7.1.0,<8",
55
+ "aresponses>=3.0.0,<4",
56
+ "ruff>=0.16.7,<0.17",
57
+ "pre-commit>=4.6.2,<5",
58
+ "twine>=7.0.0,<8",
59
+ "python-dotenv>=1.2.3,<2",
60
+ "mypy>=2.3.1,<3",
61
+ ]
62
+
63
+ [tool.ruff]
64
+ line-length = 120
65
+ target-version = "py310"
66
+ # Python inside Markdown is not formatted here. The code blocks in README.md and
67
+ # docs/ carry deliberate column alignment (see the temperature-constant table in
68
+ # docs/api-reference.md) and ruff's formatter normalises comment spacing, so
69
+ # formatting them would trade readable docs for tool uniformity. This also keeps a
70
+ # bare `ruff format .` in step with the pre-commit hooks, which are python/pyi only.
71
+ extend-exclude = ["*.md"]
72
+
73
+ [tool.ruff.lint]
74
+ select = ["E", "F", "I", "W", "UP", "B", "SIM"]
75
+ # E501 (line length) is handled by the formatter; long strings/comments are fine.
76
+ ignore = ["E501"]
77
+
78
+ [tool.mypy]
79
+ python_version = "3.10"
80
+ files = ["dimplex_controller"]
81
+ ignore_missing_imports = true
82
+ # `strict` rather than the four flags this used to carry (disallow_untyped_defs,
83
+ # warn_return_any, warn_unused_configs, warn_redundant_casts). The library is the
84
+ # layer the Home Assistant integration trusts for models, capabilities and error
85
+ # types, and that consumer runs strict mypy over its own code — so a library
86
+ # checked more loosely could hand it types that do not hold
87
+ # (dimplex-controller-hass#202).
88
+ #
89
+ # Raising it surfaced seven stale `# type: ignore` comments, all of which mypy
90
+ # reported as unused, plus one missing generic argument. No real type errors.
91
+ strict = true
92
+
93
+ [tool.pytest.ini_options]
94
+ # All async tests use asyncio; "auto" means new tests don't each need the
95
+ # @pytest.mark.asyncio decorator (existing markers remain valid).
96
+ asyncio_mode = "auto"
97
+ testpaths = ["tests"]
98
+
99
+ [tool.coverage.run]
100
+ source = ["dimplex_controller"]
101
+
102
+ [tool.coverage.report]
103
+ show_missing = true
104
+ # Guardrail so coverage can't silently regress. Raise as coverage improves.
105
+ fail_under = 80
106
+
107
+ [build-system]
108
+ requires = ["uv_build>=0.11.19,<0.13.0"]
109
+ build-backend = "uv_build"
110
+
111
+ [tool.uv.build-backend]
112
+ # The package is at the repository root, not under src/, which is what uv_build
113
+ # assumes by default. The module name is inferred from the project name
114
+ # (dimplex-controller -> dimplex_controller), so it needs no override.
115
+ module-root = ""
@@ -1,85 +0,0 @@
1
- [tool.poetry]
2
- name = "dimplex-controller"
3
- version = "0.13.1"
4
- description = "Unofficial async Python client for Dimplex (GDHV IoT) heating appliances — not affiliated with Glen Dimplex"
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
- # `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
68
-
69
- [tool.pytest.ini_options]
70
- # All async tests use asyncio; "auto" means new tests don't each need the
71
- # @pytest.mark.asyncio decorator (existing markers remain valid).
72
- asyncio_mode = "auto"
73
- testpaths = ["tests"]
74
-
75
- [tool.coverage.run]
76
- source = ["dimplex_controller"]
77
-
78
- [tool.coverage.report]
79
- show_missing = true
80
- # Guardrail so coverage can't silently regress. Raise as coverage improves.
81
- fail_under = 80
82
-
83
- [build-system]
84
- requires = ["poetry-core"]
85
- build-backend = "poetry.core.masonry.api"