dimplex-controller 0.14.0__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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dimplex-controller
3
- Version: 0.14.0
3
+ Version: 0.15.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
@@ -17,8 +17,8 @@ Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: 3.13
18
18
  Classifier: Programming Language :: Python :: 3.14
19
19
  Classifier: Topic :: Home Automation
20
- Requires-Dist: aiohttp>=3.14.3,<4
21
- Requires-Dist: pydantic>=2.13.5,<3
20
+ Requires-Dist: aiohttp>=3.9.0,<4
21
+ Requires-Dist: pydantic>=2.0.0,<3
22
22
  Requires-Python: >=3.10, <4.0
23
23
  Project-URL: Homepage, https://github.com/KRoperUK/dimplex-controller-py
24
24
  Project-URL: Repository, https://github.com/KRoperUK/dimplex-controller-py
@@ -285,7 +285,9 @@ Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_toke
285
285
  | `set_setback_temperature(...)` | Write the setback temperature (untested). |
286
286
  | `set_eco_start(hub_id, appliance_ids, enable)` | Toggle EcoStart. |
287
287
  | `set_open_window_detection(hub_id, appliance_ids, enable)` | Toggle Open Window Detection. |
288
- | `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). |
289
291
  | `get_tsi_energy_report(hub_id, ...)` | Returns `TsiEnergyReport`. |
290
292
  | `capabilities_for(appliance, *, status, product)` | Derive an `ApplianceCapabilities` matrix. |
291
293
  | `export_tokens()` / `apply_tokens(bundle)` | Token persistence helpers. |
@@ -259,7 +259,9 @@ Main client class. Construct with an `aiohttp.ClientSession` and a `refresh_toke
259
259
  | `set_setback_temperature(...)` | Write the setback temperature (untested). |
260
260
  | `set_eco_start(hub_id, appliance_ids, enable)` | Toggle EcoStart. |
261
261
  | `set_open_window_detection(hub_id, appliance_ids, enable)` | Toggle Open Window Detection. |
262
- | `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). |
263
265
  | `get_tsi_energy_report(hub_id, ...)` | Returns `TsiEnergyReport`. |
264
266
  | `capabilities_for(appliance, *, status, product)` | Derive an `ApplianceCapabilities` matrix. |
265
267
  | `export_tokens()` / `apply_tokens(bundle)` | Token persistence helpers. |
@@ -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,
@@ -840,6 +842,42 @@ class DimplexControl:
840
842
  endpoint=endpoint,
841
843
  )
842
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
+
843
881
  async def set_hot_water_boost_temperature(
844
882
  self, hub_id: str, appliance_ids: list[str], temperature: float, *, enable: bool = True
845
883
  ) -> None:
@@ -902,10 +940,12 @@ class DimplexControl:
902
940
  endpoint=endpoint,
903
941
  )
904
942
 
905
- async def get_heat_pump_hot_water_schedule(self, hub_id: str, appliance_id: str) -> TimerModeSettings:
906
- """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.
907
945
 
908
- .. 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`.
909
949
  """
910
950
  payload = {"HubId": hub_id, "ApplianceId": appliance_id}
911
951
  data = await self._request(
@@ -913,12 +953,16 @@ class DimplexControl:
913
953
  "/RemoteControl/ApiGetTimerModeDetailsForHeatPumpHwcAppliance",
914
954
  json=payload,
915
955
  )
916
- return TimerModeSettings.model_validate(data)
956
+ return HeatPumpHwcSchedule.model_validate(data)
917
957
 
918
958
  async def set_heat_pump_hot_water_schedule(self, settings: TimerModeSettings) -> TimerModeSettings:
919
959
  """Write an ASHW cylinder's schedule periods.
920
960
 
921
- .. 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.
922
966
  """
923
967
  payload = {"TimerModeSettings": settings.model_dump(mode="json")}
924
968
  await self._request("POST", "/RemoteControl/UpdateHeatPumpHwcSchedulePeriods", json=payload)
@@ -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,21 @@ 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
289
329
 
290
330
  @field_validator("ErrorCode", "WarningCode", mode="before")
291
331
  @classmethod
@@ -222,6 +222,34 @@ def parse_telemetry_points(
222
222
  return out
223
223
 
224
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
+
225
253
  def _ensure_aware(ts: datetime, default_tz: tzinfo) -> datetime:
226
254
  """Return ``ts`` with a timezone; naive datetimes are assumed ``default_tz``."""
227
255
  if ts.tzinfo is None:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dimplex-controller"
3
- version = "0.14.0"
3
+ version = "0.15.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"]
@@ -27,8 +27,8 @@ classifiers = [
27
27
  "Topic :: Home Automation",
28
28
  ]
29
29
  dependencies = [
30
- "aiohttp>=3.14.3,<4",
31
- "pydantic>=2.13.5,<3",
30
+ "aiohttp>=3.9.0,<4",
31
+ "pydantic>=2.0.0,<3",
32
32
  ]
33
33
 
34
34
  [[project.authors]]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dimplex-controller"
3
- version = "0.14.0"
3
+ version = "0.15.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"
@@ -21,12 +21,21 @@ classifiers = [
21
21
  "Programming Language :: Python :: 3.14",
22
22
  "Topic :: Home Automation",
23
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.
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.
27
26
  dependencies = [
28
- "aiohttp>=3.14.3,<4",
29
- "pydantic>=2.13.5,<3",
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",
30
39
  ]
31
40
 
32
41
  [project.urls]