dimplex-controller 0.14.0__tar.gz → 0.15.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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dimplex-controller
3
- Version: 0.14.0
3
+ Version: 0.15.1
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",
@@ -101,6 +101,10 @@ class AuthManager:
101
101
  self._access_token: str | None = bundle.access_token
102
102
  self._refresh_token: str | None = bundle.refresh_token
103
103
  self._expires_at: float = bundle.expires_at
104
+ # Serialises refreshes so concurrent callers share one POST (see
105
+ # get_access_token). Created eagerly: asyncio locks bind to the running
106
+ # loop on first use, not on construction.
107
+ self._refresh_lock = asyncio.Lock()
104
108
 
105
109
  def _request_kwargs(self) -> dict[str, Any]:
106
110
  """Common per-request kwargs (applies the configured timeout)."""
@@ -130,7 +134,17 @@ class AuthManager:
130
134
  self._expires_at = bundle.expires_at
131
135
 
132
136
  async def get_access_token(self) -> str:
133
- """Get a valid access token, refreshing if necessary."""
137
+ """Get a valid access token, refreshing if necessary.
138
+
139
+ Refresh is single-flight. Callers that see an expired token queue on
140
+ :attr:`_refresh_lock` and re-check expiry once they hold it, so only the
141
+ first performs the ``POST /token`` and the rest reuse its result. The
142
+ cloud rotates the refresh token on every refresh, so two concurrent
143
+ refreshes sent the same token meant all but one were answered
144
+ ``invalid_grant`` — a permanent auth failure produced by ordinary
145
+ concurrency (dimplex-controller-py#139). A failed refresh raises through
146
+ ``async with``, leaving the lock free for the next caller.
147
+ """
134
148
  if not self._refresh_token:
135
149
  raise DimplexAuthInvalidGrantError(
136
150
  "No refresh token available. User must authenticate first.",
@@ -141,8 +155,11 @@ class AuthManager:
141
155
  assert self._access_token is not None
142
156
  return self._access_token
143
157
 
144
- # Token expired or missing, try refresh
145
- await self.refresh_tokens()
158
+ async with self._refresh_lock:
159
+ # Another caller may have refreshed while we waited for the lock.
160
+ if not self.is_authenticated:
161
+ await self.refresh_tokens()
162
+
146
163
  if not self._access_token:
147
164
  raise DimplexAuthInvalidGrantError(
148
165
  "Token refresh succeeded but no access token was returned.",
@@ -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
@@ -25,7 +26,8 @@ from .const import (
25
26
  HEADER_DEVICE_OS,
26
27
  HEADER_DEVICE_VERSION,
27
28
  HEADER_USER_AGENT,
28
- HTTP_OK,
29
+ MODE_TEMP_MAX,
30
+ MODE_TEMP_MIN,
29
31
  NO_SETPOINT_SENTINEL,
30
32
  NULL_DATETIME,
31
33
  )
@@ -36,6 +38,7 @@ from .models import (
36
38
  ApplianceModeSettings,
37
39
  ApplianceModeStatus,
38
40
  ApplianceStatus,
41
+ HeatPumpHwcSchedule,
39
42
  Hub,
40
43
  HygieneFrequency,
41
44
  ProductModel,
@@ -92,6 +95,28 @@ def _clamp_away_temperature(temperature: float) -> float:
92
95
  return clamped
93
96
 
94
97
 
98
+ def _clamp_mode_temperature(temperature: float) -> float:
99
+ """Clamp a setpoint into the 7–30 °C the app's mode carousels offer.
100
+
101
+ The wire field is a byte, so an out-of-range value used to be sent verbatim
102
+ and only reduced (or ignored) by the cloud — the write reported success for
103
+ a setpoint the appliance never reached. Clamping, as Away already does,
104
+ keeps the requested value and the applied value in agreement and makes the
105
+ adjustment visible (dimplex-controller-py#141).
106
+ """
107
+ value = float(temperature)
108
+ clamped = min(max(value, MODE_TEMP_MIN), MODE_TEMP_MAX)
109
+ if clamped != value:
110
+ _LOGGER.warning(
111
+ "Setpoint %.1f °C is outside the %s–%s °C the app offers; using %.1f °C",
112
+ value,
113
+ MODE_TEMP_MIN,
114
+ MODE_TEMP_MAX,
115
+ clamped,
116
+ )
117
+ return clamped
118
+
119
+
95
120
  def _coerce_timeout(timeout: float | aiohttp.ClientTimeout | None) -> aiohttp.ClientTimeout | None:
96
121
  """Normalise a timeout value into an :class:`aiohttp.ClientTimeout`.
97
122
 
@@ -258,7 +283,11 @@ class DimplexControl:
258
283
  for attempt in range(attempts):
259
284
  try:
260
285
  async with self._session.request(method, url, headers=headers, **kwargs) as resp:
261
- if resp.status == HTTP_OK:
286
+ # Any 2xx is success. Control endpoints may answer 204 No
287
+ # Content, and an empty body is already what
288
+ # _decode_ok_body handles; gating on exactly 200 raised a
289
+ # successful write as an API error (dimplex-controller-py#141).
290
+ if 200 <= resp.status < 300:
262
291
  return await self._decode_ok_body(resp)
263
292
 
264
293
  text = await resp.text()
@@ -582,18 +611,27 @@ class DimplexControl:
582
611
  with ``Status=1`` (on) / ``Status=0`` (off); ``Time`` carries the boost
583
612
  duration in minutes and ``Temperature`` the boost target (7–30 °C).
584
613
 
614
+ A non-positive ``duration_minutes`` raises :class:`ValueError` when
615
+ engaging: the cloud accepted ``Time=0`` and the appliance simply did not
616
+ boost, so the caller was told a boost had started that never would
617
+ (dimplex-controller-py#141). Clearing (``enable=False``) is unaffected —
618
+ it sends ``Time=0`` by design.
619
+
585
620
  .. versionchanged:: 0.13.0
586
621
  Previously sent ``ApplianceModes=16``, which is **Advance** — the
587
622
  appliance advanced its schedule instead of boosting, and the
588
623
  requested duration was discarded.
589
624
  """
625
+ minutes = int(duration_minutes)
626
+ if enable and minutes <= 0:
627
+ raise ValueError(f"boost duration must be a positive number of minutes, got {duration_minutes!r}")
590
628
  await self.set_mode_flag(
591
629
  hub_id,
592
630
  appliance_ids,
593
631
  ApplianceModeFlag.BOOST,
594
632
  enable=enable,
595
633
  temperature=temperature,
596
- minutes=int(duration_minutes) if enable else 0,
634
+ minutes=minutes if enable else 0,
597
635
  )
598
636
 
599
637
  async def clear_boost(
@@ -761,12 +799,16 @@ class DimplexControl:
761
799
  the schedule and is rejected with HTTP 403 on Quantum.
762
800
 
763
801
  ``Temperature`` is a wire ``byte``, so the value is rounded to whole
764
- degrees.
802
+ degrees, and it is clamped into the 7–30 °C range the app offers
803
+ (:data:`~dimplex_controller.MODE_TEMP_MIN` /
804
+ :data:`~dimplex_controller.MODE_TEMP_MAX`) with a warning when it is
805
+ outside — the cloud silently reduces such a value
806
+ (dimplex-controller-py#141).
765
807
  """
766
808
  payload = {
767
809
  "HubId": hub_id,
768
810
  "ApplianceIds": appliance_ids,
769
- "Temperature": int(round(temperature)),
811
+ "Temperature": int(round(_clamp_mode_temperature(temperature))),
770
812
  }
771
813
  await self._request("POST", "/RemoteControl/SetApplianceSetpointTemperature", json=payload)
772
814
 
@@ -783,7 +825,8 @@ class DimplexControl:
783
825
  ``status`` is an :class:`~dimplex_controller.SetbackStatus` byte —
784
826
  ``ACTIVE``/``INACTIVE`` for a plain setback, with ``DSM_MODE`` and
785
827
  ``LOCAL_FREQUENCY_CONTROL_ACTIVE`` reserved for demand-side-management
786
- use.
828
+ use. ``temperature`` is rounded and clamped into 7–30 °C, as
829
+ :meth:`set_appliance_setpoint_temperature` does.
787
830
 
788
831
  Confirmed present in APK 2.26.0 but **not yet validated on live
789
832
  hardware**.
@@ -792,7 +835,7 @@ class DimplexControl:
792
835
  "HubId": hub_id,
793
836
  "ApplianceIds": appliance_ids,
794
837
  "Status": int(status),
795
- "Temperature": int(round(temperature)),
838
+ "Temperature": int(round(_clamp_mode_temperature(temperature))),
796
839
  }
797
840
  await self._request("POST", "/RemoteControl/SetSetbackTemperature", json=payload)
798
841
 
@@ -840,6 +883,42 @@ class DimplexControl:
840
883
  endpoint=endpoint,
841
884
  )
842
885
 
886
+ async def set_hot_water_holiday(
887
+ self,
888
+ hub_id: str,
889
+ appliance_ids: list[str],
890
+ until: datetime | None = None,
891
+ *,
892
+ enable: bool = True,
893
+ heat_pump: bool = False,
894
+ ) -> None:
895
+ """Put a cylinder on Holiday (standby) until ``until``, or clear it.
896
+
897
+ Verified on a live ASHW cylinder (EDL200UK-630RF) with ``heat_pump=True``:
898
+ engaging moved ``ApplianceModes`` 8192 → 8200 and set
899
+ ``HolidayNumOfDays``, heating stopped, and clearing restored 8192. The
900
+ cylinder counts holidays in whole days, so ``until`` is also sent as a
901
+ rounded-up day count; a caller that wants a time of day has to clear
902
+ Holiday itself at that time.
903
+ """
904
+ days = 0
905
+ if enable:
906
+ if until is None:
907
+ raise ValueError("a holiday needs an end time")
908
+ end = until if until.tzinfo is not None else until.replace(tzinfo=timezone.utc)
909
+ seconds = (end - datetime.now(timezone.utc)).total_seconds()
910
+ days = max(1, math.ceil(seconds / 86400))
911
+ endpoint = "/RemoteControl/SetApplianceModeHeatPumpHwc" if heat_pump else "/RemoteControl/SetApplianceModeHwc"
912
+ await self.set_mode_flag(
913
+ hub_id,
914
+ appliance_ids,
915
+ ApplianceModeFlag.HOLIDAY,
916
+ enable=enable,
917
+ until=until,
918
+ number_of_days=days,
919
+ endpoint=endpoint,
920
+ )
921
+
843
922
  async def set_hot_water_boost_temperature(
844
923
  self, hub_id: str, appliance_ids: list[str], temperature: float, *, enable: bool = True
845
924
  ) -> None:
@@ -902,10 +981,12 @@ class DimplexControl:
902
981
  endpoint=endpoint,
903
982
  )
904
983
 
905
- async def get_heat_pump_hot_water_schedule(self, hub_id: str, appliance_id: str) -> TimerModeSettings:
906
- """Read an ASHW cylinder's schedule.
984
+ async def get_heat_pump_hot_water_schedule(self, hub_id: str, appliance_id: str) -> HeatPumpHwcSchedule:
985
+ """Read an ASHW cylinder's weekly schedule.
907
986
 
908
- .. warning:: Untested — APK-confirmed only.
987
+ Verified on a live ASHW cylinder (EDL200UK-630RF). The reply does not fit
988
+ :class:`TimerModeSettings` — there is no ``TimerMode`` and each period is
989
+ ``{DayOfWeek, StartTime, Mode}`` — hence :class:`HeatPumpHwcSchedule`.
909
990
  """
910
991
  payload = {"HubId": hub_id, "ApplianceId": appliance_id}
911
992
  data = await self._request(
@@ -913,12 +994,16 @@ class DimplexControl:
913
994
  "/RemoteControl/ApiGetTimerModeDetailsForHeatPumpHwcAppliance",
914
995
  json=payload,
915
996
  )
916
- return TimerModeSettings.model_validate(data)
997
+ return HeatPumpHwcSchedule.model_validate(data)
917
998
 
918
999
  async def set_heat_pump_hot_water_schedule(self, settings: TimerModeSettings) -> TimerModeSettings:
919
1000
  """Write an ASHW cylinder's schedule periods.
920
1001
 
921
- .. warning:: Untested — APK-confirmed only.
1002
+ .. warning:: Does not work yet. On a live ASHW cylinder this endpoint
1003
+ answered HTTP 403 to every body tried — wrapped in ``TimerModeSettings``
1004
+ or ``Settings``, with or without ``TimerMode``, integer or ``HH:MM:SS``
1005
+ times, and app versions up to 3.0.0 — while unwrapped bodies got 400.
1006
+ The request the official app sends has not been captured.
922
1007
  """
923
1008
  payload = {"TimerModeSettings": settings.model_dump(mode="json")}
924
1009
  await self._request("POST", "/RemoteControl/UpdateHeatPumpHwcSchedulePeriods", json=payload)
@@ -144,6 +144,35 @@ class ProductModel(BaseModel):
144
144
  return None
145
145
 
146
146
 
147
+ _DEFAULT_DATETIME = datetime(1, 1, 1)
148
+
149
+
150
+ def _is_null_datetime(value: str) -> bool:
151
+ """True when ``value`` is the cloud's ``default(DateTime)`` sentinel."""
152
+ text = value.strip()
153
+ if not text.startswith(NULL_DATETIME):
154
+ return False
155
+ # Tolerate .NET's optional fractional seconds, ``Z`` suffix or offset, e.g.
156
+ # ``0001-01-01T00:00:00Z`` or ``0001-01-01T00:00:00.0000000+00:00``.
157
+ return not text[len(NULL_DATETIME) :].strip(".0Z+:")
158
+
159
+
160
+ def _map_null_datetime(value: Any) -> Any:
161
+ """Return ``None`` for the .NET null datetime, leaving real values alone.
162
+
163
+ The cloud sends ``0001-01-01T00:00:00`` for an unset date, which Pydantic
164
+ parsed into ``datetime(1, 1, 1)`` — so an unknown installation date or last
165
+ telemetry time rendered as year 1 instead of "unknown"
166
+ (dimplex-controller-py#140). Real timestamps are returned untouched, so
167
+ Pydantic still parses them (including fractional seconds).
168
+ """
169
+ if isinstance(value, datetime):
170
+ return None if value.replace(tzinfo=None) == _DEFAULT_DATETIME else value
171
+ if isinstance(value, str) and _is_null_datetime(value):
172
+ return None
173
+ return value
174
+
175
+
147
176
  class Appliance(BaseModel):
148
177
  ApplianceId: str
149
178
  ApplianceType: str
@@ -161,6 +190,12 @@ class Appliance(BaseModel):
161
190
  FirmwareVersion: str | None = None
162
191
  ProductModelExtensions: dict[str, str] | None = None
163
192
 
193
+ @field_validator("InstallationDate", "LastTelemDate", mode="before")
194
+ @classmethod
195
+ def _map_null_dates(cls, value: Any) -> Any:
196
+ """Map the cloud's unset-date sentinel to ``None`` rather than year 1."""
197
+ return _map_null_datetime(value)
198
+
164
199
  @field_validator("ProductModelExtensions", mode="before")
165
200
  @classmethod
166
201
  def _coerce_provisioning_extensions(cls, value: Any) -> dict[str, str] | None:
@@ -224,6 +259,12 @@ class Hub(BaseModel):
224
259
  IsDefault: bool | None = None
225
260
  RoleName: str | None = None
226
261
 
262
+ @field_validator("HubRegistrationDate", "InstallationDate", "LastTelemDate", mode="before")
263
+ @classmethod
264
+ def _map_null_dates(cls, value: Any) -> Any:
265
+ """Map the cloud's unset-date sentinel to ``None`` rather than year 1."""
266
+ return _map_null_datetime(value)
267
+
227
268
 
228
269
  class TimerPeriod(BaseModel):
229
270
  DayOfWeek: int
@@ -255,6 +296,31 @@ class TimerModeSettings(BaseModel):
255
296
  TimerPeriods: list[TimerPeriod] = Field(default_factory=list)
256
297
 
257
298
 
299
+ class HeatPumpHwcPeriod(BaseModel):
300
+ """One period of an ASHW cylinder's weekly schedule.
301
+
302
+ A live cylinder returns one ``{DayOfWeek, StartTime: 0, Mode: 2}`` per day.
303
+ ``DayOfWeek`` is .NET's (0 = Sunday). ``StartTime`` is an integer, presumed
304
+ minutes after midnight, and what each ``Mode`` value means is not yet known.
305
+ """
306
+
307
+ DayOfWeek: int
308
+ StartTime: int
309
+ Mode: int
310
+
311
+
312
+ class HeatPumpHwcSchedule(BaseModel):
313
+ """``ApiGetTimerModeDetailsForHeatPumpHwcAppliance`` reply.
314
+
315
+ Unlike the room-heater :class:`TimerModeSettings` it has no ``TimerMode``
316
+ and its periods carry a mode instead of an end time and temperature.
317
+ """
318
+
319
+ HubId: str
320
+ ApplianceId: str
321
+ TimerPeriods: list[HeatPumpHwcPeriod] = Field(default_factory=list)
322
+
323
+
258
324
  class UserContext(BaseModel):
259
325
  Id: str
260
326
  EmailAddress: str | None = None
@@ -286,6 +352,21 @@ class ApplianceStatus(BaseModel):
286
352
  LockStatus: int | None = None
287
353
  ErrorCode: str | None = None
288
354
  WarningCode: str | None = None
355
+ # Hot-water cylinder fields, as a live ASHW cylinder (EDL200UK-630RF) sends
356
+ # them. AvailableHotWater above is litres of usable hot water (the app shows
357
+ # it as "336 L Hot water").
358
+ Capacity: int | None = None # tank size, litres
359
+ HeatingUp: bool | None = None
360
+ IsGreenEnergyActive: bool | None = None
361
+ HygieneFrequency: int | None = None # EFrequency, see HygieneFrequency
362
+ HygieneTemperature: float | None = None
363
+ HolidayNumOfDays: int | None = None
364
+ # Present in the same reply, always null on the cylinder seen so far.
365
+ DelayedStartTime: str | None = None
366
+ DsmStatus: int | None = None
367
+ ModifiedPower: float | None = None
368
+ ConnectedChargeProfiles: Any = None
369
+ StandaloneChargeProfiles: Any = None
289
370
 
290
371
  @field_validator("ErrorCode", "WarningCode", mode="before")
291
372
  @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.1"
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.1"
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]