dimplex-controller 0.9.0__tar.gz → 0.10.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.9.0
3
+ Version: 0.10.1
4
4
  Summary: Python client for Dimplex heating controllers (GDHV IoT)
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -167,6 +167,8 @@ for status in status_list:
167
167
  print(f"Comfort status: {status.ComfortStatus}")
168
168
  ```
169
169
 
170
+ > **A note on empty responses:** when every requested appliance is offline (e.g. radiators switched off at the wall) the cloud returns HTTP 200 with an empty list. `get_appliance_overview` surfaces that as `[]` — it is **not** an error. If you need a stable id → status mapping, use `get_appliance_overview_map(...)`, which fills in `None` for missing ids.
171
+
170
172
  ### Sending control commands
171
173
 
172
174
  ```python
@@ -198,7 +200,11 @@ for appliance_id, telemetry in report.ApplianceTelemetryData.items():
198
200
  print(f"{appliance_id}: today={daily.total_kwh} kWh, lifetime={lifetime.total_kwh} kWh")
199
201
  ```
200
202
 
201
- `parse_telemetry_points` normalises firmware-varying point shapes. `summarise_energy` builds **daily** (local midnight) and **lifetime** totals. With `include_previous_period=True` the cloud often returns full history — filter client-side rather than trusting `days_back` alone.
203
+ `parse_telemetry_points` normalises firmware-varying point shapes. `summarise_energy` builds **daily** (local midnight) and **lifetime** totals **per register**. `T1` (off-peak / cheaper) and `T2` (peak / more expensive) must not be summed; parse with `VALUE_KEY_T1` / `VALUE_KEY_T2`. With `include_previous_period=True` the cloud often returns full history — filter client-side rather than trusting `days_back` alone.
204
+
205
+ ## Compatibility
206
+
207
+ See [docs/compatibility.md](docs/compatibility.md) for the library ↔ Home Assistant version matrix.
202
208
 
203
209
  ## Configuration
204
210
 
@@ -277,8 +283,41 @@ If `parse_telemetry_points` returns an empty list, the API likely returned an un
277
283
 
278
284
  The GDHV cloud API has rate limits. If you hit them, back off for a few minutes before retrying. The library does not currently implement automatic retries with back-off.
279
285
 
286
+ ### `get_appliance_overview` returns an empty list
287
+
288
+ This is the cloud's normal response when every requested appliance is offline (e.g. radiators turned off at the wall, or a hub that has dropped off the network). It is **not** an error — `get_appliance_overview` returns `[]` and `get_appliance_overview_map` returns a dict of `None` values. Treat the call as a successful poll; the appliances will reappear in subsequent calls once they come back online. See the note in [Reading status](#reading-status) for details.
289
+
290
+
291
+ ## CLI
292
+
293
+ Install the package (or an editable install) to get the `dimplex` console script:
294
+
295
+ ```bash
296
+ pip install dimplex-controller
297
+ export DIMPLEX_REFRESH_TOKEN=... # never commit this
298
+ dimplex login
299
+ dimplex hubs
300
+ dimplex zones --hub <hub-id> -v
301
+ dimplex status <hub-id> <appliance-id>
302
+ dimplex energy <hub-id> --days 30
303
+ # control writes require --yes
304
+ dimplex boost <hub-id> <appliance-id> --minutes 60 --yes
305
+ ```
306
+
307
+ Tokens can also come from a JSON file (`--tokens-file` / `DIMPLEX_TOKENS_FILE`) with keys `refresh_token`, `access_token`, `expires_at`. Secrets are redacted in CLI output unless `--show-tokens` is passed.
308
+
280
309
  ## Contributing
281
310
 
311
+ ### Branch protection (`main`)
312
+
313
+ Pull requests into `main` must keep the **`ci`** GitHub Actions check green.
314
+
315
+ - Changes under `dimplex_controller/`, `tests/`, or CI config run **lint**, **pre-commit**, and the **pytest matrix** (Python 3.10–3.13). The `ci` job fails if any of those fail.
316
+ - Docs-only PRs still report a green `ci` without running the full matrix.
317
+
318
+ Direct pushes to `main` are blocked (PR + squash only; no force-push/delete). Commits must be signed (repo-wide rule).
319
+
320
+
282
321
  Contributions are welcome! Please read the [contributing guidelines](CONTRIBUTING.md) before opening a pull request.
283
322
 
284
323
  Key points:
@@ -139,6 +139,8 @@ for status in status_list:
139
139
  print(f"Comfort status: {status.ComfortStatus}")
140
140
  ```
141
141
 
142
+ > **A note on empty responses:** when every requested appliance is offline (e.g. radiators switched off at the wall) the cloud returns HTTP 200 with an empty list. `get_appliance_overview` surfaces that as `[]` — it is **not** an error. If you need a stable id → status mapping, use `get_appliance_overview_map(...)`, which fills in `None` for missing ids.
143
+
142
144
  ### Sending control commands
143
145
 
144
146
  ```python
@@ -170,7 +172,11 @@ for appliance_id, telemetry in report.ApplianceTelemetryData.items():
170
172
  print(f"{appliance_id}: today={daily.total_kwh} kWh, lifetime={lifetime.total_kwh} kWh")
171
173
  ```
172
174
 
173
- `parse_telemetry_points` normalises firmware-varying point shapes. `summarise_energy` builds **daily** (local midnight) and **lifetime** totals. With `include_previous_period=True` the cloud often returns full history — filter client-side rather than trusting `days_back` alone.
175
+ `parse_telemetry_points` normalises firmware-varying point shapes. `summarise_energy` builds **daily** (local midnight) and **lifetime** totals **per register**. `T1` (off-peak / cheaper) and `T2` (peak / more expensive) must not be summed; parse with `VALUE_KEY_T1` / `VALUE_KEY_T2`. With `include_previous_period=True` the cloud often returns full history — filter client-side rather than trusting `days_back` alone.
176
+
177
+ ## Compatibility
178
+
179
+ See [docs/compatibility.md](docs/compatibility.md) for the library ↔ Home Assistant version matrix.
174
180
 
175
181
  ## Configuration
176
182
 
@@ -249,8 +255,41 @@ If `parse_telemetry_points` returns an empty list, the API likely returned an un
249
255
 
250
256
  The GDHV cloud API has rate limits. If you hit them, back off for a few minutes before retrying. The library does not currently implement automatic retries with back-off.
251
257
 
258
+ ### `get_appliance_overview` returns an empty list
259
+
260
+ This is the cloud's normal response when every requested appliance is offline (e.g. radiators turned off at the wall, or a hub that has dropped off the network). It is **not** an error — `get_appliance_overview` returns `[]` and `get_appliance_overview_map` returns a dict of `None` values. Treat the call as a successful poll; the appliances will reappear in subsequent calls once they come back online. See the note in [Reading status](#reading-status) for details.
261
+
262
+
263
+ ## CLI
264
+
265
+ Install the package (or an editable install) to get the `dimplex` console script:
266
+
267
+ ```bash
268
+ pip install dimplex-controller
269
+ export DIMPLEX_REFRESH_TOKEN=... # never commit this
270
+ dimplex login
271
+ dimplex hubs
272
+ dimplex zones --hub <hub-id> -v
273
+ dimplex status <hub-id> <appliance-id>
274
+ dimplex energy <hub-id> --days 30
275
+ # control writes require --yes
276
+ dimplex boost <hub-id> <appliance-id> --minutes 60 --yes
277
+ ```
278
+
279
+ Tokens can also come from a JSON file (`--tokens-file` / `DIMPLEX_TOKENS_FILE`) with keys `refresh_token`, `access_token`, `expires_at`. Secrets are redacted in CLI output unless `--show-tokens` is passed.
280
+
252
281
  ## Contributing
253
282
 
283
+ ### Branch protection (`main`)
284
+
285
+ Pull requests into `main` must keep the **`ci`** GitHub Actions check green.
286
+
287
+ - Changes under `dimplex_controller/`, `tests/`, or CI config run **lint**, **pre-commit**, and the **pytest matrix** (Python 3.10–3.13). The `ci` job fails if any of those fail.
288
+ - Docs-only PRs still report a green `ci` without running the full matrix.
289
+
290
+ Direct pushes to `main` are blocked (PR + squash only; no force-push/delete). Commits must be signed (repo-wide rule).
291
+
292
+
254
293
  Contributions are welcome! Please read the [contributing guidelines](CONTRIBUTING.md) before opening a pull request.
255
294
 
256
295
  Key points:
@@ -1,6 +1,18 @@
1
- """Dimplex Controller Client."""
1
+ """Dimplex Controller Client.
2
+
3
+ Async Python client for the Glen Dimplex Heating & Ventilation (GDHV) cloud
4
+ API. See :class:`~dimplex_controller.client.DimplexControl` for the entry
5
+ point and :meth:`DimplexControl.get_appliance_overview` for the read path
6
+ used by the Home Assistant integration.
7
+
8
+ A note on the API: ``get_appliance_overview`` may return an empty list
9
+ with HTTP 200 when the requested appliances are offline. That is a
10
+ successful poll, not an error — use ``get_appliance_overview_map`` if you
11
+ need a stable id → status mapping.
12
+ """
2
13
 
3
14
  from .auth import TokenBundle
15
+ from .capabilities import ApplianceCapabilities, capabilities_for
4
16
  from .client import DimplexControl
5
17
  from .exceptions import (
6
18
  DimplexApiError,
@@ -27,6 +39,7 @@ from .models import (
27
39
  Zone,
28
40
  )
29
41
  from .telemetry import (
42
+ VALUE_KEY_T1,
30
43
  VALUE_KEY_T2,
31
44
  EnergySummary,
32
45
  filter_telemetry_points,
@@ -37,6 +50,8 @@ from .telemetry import (
37
50
  __all__ = [
38
51
  "DimplexControl",
39
52
  "TokenBundle",
53
+ "ApplianceCapabilities",
54
+ "capabilities_for",
40
55
  "Hub",
41
56
  "Zone",
42
57
  "Appliance",
@@ -53,6 +68,7 @@ __all__ = [
53
68
  "filter_telemetry_points",
54
69
  "summarise_energy",
55
70
  "EnergySummary",
71
+ "VALUE_KEY_T1",
56
72
  "VALUE_KEY_T2",
57
73
  "DimplexError",
58
74
  "DimplexApiError",
@@ -0,0 +1,178 @@
1
+ """Appliance capability matrix derived from model, provisioning, and status.
2
+
3
+ Clients (Home Assistant climate, CLIs, etc.) should gate UI and control paths
4
+ with these flags rather than hard-coding product assumptions.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from .models import Appliance, ApplianceStatus, AutomaticProvisioning, ProductModel
13
+
14
+ # Default boost lengths (minutes) offered by the mobile app for most heaters.
15
+ DEFAULT_BOOST_DURATIONS: tuple[int, ...] = (30, 60, 120, 180)
16
+ DEFAULT_BOOST_MINUTES = 60
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ApplianceCapabilities:
21
+ """Structured capability flags for one appliance.
22
+
23
+ Flags are best-effort: when status/product metadata is incomplete the
24
+ library prefers enabling known cloud control paths (boost/away/OWD) so
25
+ users are not locked out of working RPCs.
26
+ """
27
+
28
+ boost: bool = True
29
+ away: bool = True
30
+ open_window: bool = True
31
+ eco_start: bool = True
32
+ setback_read: bool = True
33
+ setback_write: bool = False # no confirmed write API yet
34
+ frost: bool = True # TimerMode.FROST_PROTECTION
35
+ timer: bool = True
36
+ energy_meter: bool = False
37
+ storage: bool = False
38
+ hot_water: bool = False
39
+ climate: bool = True
40
+ min_temp: float = 5.0
41
+ max_temp: float = 30.0
42
+ default_boost_minutes: int = DEFAULT_BOOST_MINUTES
43
+ boost_durations: tuple[int, ...] = DEFAULT_BOOST_DURATIONS
44
+
45
+ def climate_presets(self) -> list[str]:
46
+ """Return HA-style climate preset keys supported by this appliance."""
47
+ presets = ["comfort"]
48
+ if self.boost:
49
+ presets.append("boost")
50
+ if self.away:
51
+ presets.append("away")
52
+ if self.eco_start:
53
+ presets.append("eco")
54
+ return presets
55
+
56
+ def as_dict(self) -> dict[str, Any]:
57
+ """JSON-serialisable snapshot (for diagnostics / logging)."""
58
+ return {
59
+ "boost": self.boost,
60
+ "away": self.away,
61
+ "open_window": self.open_window,
62
+ "eco_start": self.eco_start,
63
+ "setback_read": self.setback_read,
64
+ "setback_write": self.setback_write,
65
+ "frost": self.frost,
66
+ "timer": self.timer,
67
+ "energy_meter": self.energy_meter,
68
+ "storage": self.storage,
69
+ "hot_water": self.hot_water,
70
+ "climate": self.climate,
71
+ "min_temp": self.min_temp,
72
+ "max_temp": self.max_temp,
73
+ "default_boost_minutes": self.default_boost_minutes,
74
+ "boost_durations": list(self.boost_durations),
75
+ "climate_presets": self.climate_presets(),
76
+ }
77
+
78
+
79
+ def _type_tokens(appliance: Appliance | None, product: ProductModel | None) -> str:
80
+ parts: list[str] = []
81
+ if appliance is not None:
82
+ for attr in ("ApplianceType", "ApplianceModel", "FriendlyName"):
83
+ value = getattr(appliance, attr, None)
84
+ if value:
85
+ parts.append(str(value))
86
+ if product is not None:
87
+ for attr in ("ProductTypeName", "ProductModelName"):
88
+ value = getattr(product, attr, None)
89
+ if value:
90
+ parts.append(str(value))
91
+ return " ".join(parts).lower()
92
+
93
+
94
+ def _provisioning(appliance: Appliance | None, product: ProductModel | None) -> AutomaticProvisioning | None:
95
+ if appliance is not None:
96
+ prov = appliance.automatic_provisioning
97
+ if prov is not None:
98
+ return prov
99
+ if product is not None:
100
+ return product.automatic_provisioning
101
+ return None
102
+
103
+
104
+ def capabilities_for(
105
+ appliance: Appliance | None = None,
106
+ *,
107
+ status: ApplianceStatus | None = None,
108
+ product: ProductModel | None = None,
109
+ ) -> ApplianceCapabilities:
110
+ """Derive capability flags for an appliance.
111
+
112
+ Sources (in roughly increasing specificity):
113
+
114
+ * product catalogue / type name heuristics
115
+ * ``AUTOMATIC_PROVISIONING`` (rated power, storage)
116
+ * live overview fields (boost/away/OWD/setback/hot water)
117
+
118
+ When a status field is present (including ``False`` / ``0``) the related
119
+ feature is treated as supported. Missing fields leave defaults (generally
120
+ enabled for control paths the cloud exposes generically).
121
+ """
122
+ tokens = _type_tokens(appliance, product)
123
+ prov = _provisioning(appliance, product)
124
+
125
+ storage = False
126
+ energy_meter = False
127
+ hot_water = False
128
+ if prov is not None:
129
+ if prov.charge_capacity is not None and prov.charge_capacity > 0:
130
+ storage = True
131
+ if prov.rated_power is not None and prov.rated_power > 0:
132
+ energy_meter = True # metered family often has TSI history too
133
+
134
+ if any(k in tokens for k in ("quantum", "storage", "qrad", "charge")):
135
+ storage = True
136
+ energy_meter = True
137
+ if any(k in tokens for k in ("hot water", "hotwater", "cylinder", "dhw")):
138
+ hot_water = True
139
+
140
+ boost = True
141
+ away = True
142
+ open_window = True
143
+ eco_start = True
144
+ setback_read = True
145
+ frost = True
146
+ timer = True
147
+ climate = not hot_water # cylinder-only appliances are not room climate
148
+
149
+ if status is not None:
150
+ if status.BoostDuration is not None or status.BoostTemperature is not None:
151
+ boost = True
152
+ if status.AwayDateTime is not None or status.AwayTemperature is not None:
153
+ away = True
154
+ if status.OpenWindowEnabled is not None:
155
+ open_window = True
156
+ if status.EcoStartEnabled is not None:
157
+ eco_start = True
158
+ if status.SetbackEnabled is not None or status.SetbackTemperature is not None:
159
+ setback_read = True
160
+ if status.AvailableHotWater is not None:
161
+ hot_water = True
162
+ if status.RoomTemperature is not None or status.ActiveSetPointTemperature is not None:
163
+ climate = True
164
+
165
+ return ApplianceCapabilities(
166
+ boost=boost,
167
+ away=away,
168
+ open_window=open_window,
169
+ eco_start=eco_start,
170
+ setback_read=setback_read,
171
+ setback_write=False,
172
+ frost=frost,
173
+ timer=timer,
174
+ energy_meter=energy_meter,
175
+ storage=storage,
176
+ hot_water=hot_water,
177
+ climate=climate,
178
+ )
@@ -0,0 +1,266 @@
1
+ """Command-line interface for smoke-testing Dimplex cloud access.
2
+
3
+ Tokens are read from environment variables by default:
4
+
5
+ * ``DIMPLEX_REFRESH_TOKEN``
6
+ * ``DIMPLEX_ACCESS_TOKEN`` (optional)
7
+ * ``DIMPLEX_EXPIRES_AT`` (optional unix timestamp)
8
+
9
+ Or from a JSON file via ``--tokens-file`` (keys: refresh_token, access_token,
10
+ expires_at). Secrets are never printed unless ``--show-tokens`` is passed.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import asyncio
17
+ import contextlib
18
+ import json
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import aiohttp
25
+
26
+ from .auth import TokenBundle
27
+ from .client import DimplexControl
28
+ from .exceptions import DimplexError
29
+
30
+
31
+ def _load_tokens(path: Path | None) -> TokenBundle:
32
+ if path is not None:
33
+ data = json.loads(path.read_text(encoding="utf-8"))
34
+ return TokenBundle.from_mapping(data)
35
+ return TokenBundle(
36
+ refresh_token=os.environ.get("DIMPLEX_REFRESH_TOKEN"),
37
+ access_token=os.environ.get("DIMPLEX_ACCESS_TOKEN"),
38
+ expires_at=float(os.environ.get("DIMPLEX_EXPIRES_AT") or 0),
39
+ )
40
+
41
+
42
+ def _save_tokens(path: Path, bundle: TokenBundle) -> None:
43
+ path.write_text(json.dumps(bundle.as_dict(), indent=2) + "\n", encoding="utf-8")
44
+ with contextlib.suppress(OSError):
45
+ path.chmod(0o600)
46
+
47
+
48
+ def _redact(value: str | None, *, show: bool) -> str:
49
+ if not value:
50
+ return "(none)"
51
+ if show:
52
+ return value
53
+ if len(value) <= 8:
54
+ return "***"
55
+ return f"{value[:4]}…{value[-4:]} ({len(value)} chars)"
56
+
57
+
58
+ async def _with_client(
59
+ args: argparse.Namespace,
60
+ coro_factory: Any,
61
+ ) -> int:
62
+ tokens = _load_tokens(Path(args.tokens_file) if args.tokens_file else None)
63
+ if not tokens.refresh_token and not tokens.access_token:
64
+ print(
65
+ "error: no tokens — set DIMPLEX_REFRESH_TOKEN or pass --tokens-file",
66
+ file=sys.stderr,
67
+ )
68
+ return 2
69
+
70
+ async with aiohttp.ClientSession() as session:
71
+ client = DimplexControl(session, token_bundle=tokens)
72
+ try:
73
+ await client.auth.get_access_token()
74
+ if args.tokens_file:
75
+ _save_tokens(Path(args.tokens_file), client.export_tokens())
76
+ return await coro_factory(client)
77
+ except DimplexError as exc:
78
+ print(f"error: {exc}", file=sys.stderr)
79
+ return 1
80
+
81
+
82
+ async def cmd_login(client: DimplexControl, args: argparse.Namespace) -> int:
83
+ """Validate tokens and optionally write them back."""
84
+ bundle = client.export_tokens()
85
+ print("authenticated")
86
+ print(f" refresh_token: {_redact(bundle.refresh_token, show=args.show_tokens)}")
87
+ print(f" access_token: {_redact(bundle.access_token, show=args.show_tokens)}")
88
+ print(f" expires_at: {bundle.expires_at}")
89
+ if args.tokens_file:
90
+ _save_tokens(Path(args.tokens_file), bundle)
91
+ print(f" wrote: {args.tokens_file}")
92
+ return 0
93
+
94
+
95
+ async def cmd_hubs(client: DimplexControl, args: argparse.Namespace) -> int:
96
+ hubs = await client.get_hubs()
97
+ for hub in hubs:
98
+ name = hub.FriendlyName or hub.Name or "(unnamed)"
99
+ print(f"{hub.HubId}\t{name}\tconnection={hub.ConnectionState}")
100
+ if not hubs:
101
+ print("(no hubs)")
102
+ return 0
103
+
104
+
105
+ async def cmd_zones(client: DimplexControl, args: argparse.Namespace) -> int:
106
+ hubs = await client.get_hubs()
107
+ hub_id = args.hub or (hubs[0].HubId if hubs else None)
108
+ if not hub_id:
109
+ print("error: no hub id", file=sys.stderr)
110
+ return 2
111
+ zones = await client.get_hub_zones(hub_id)
112
+ for zone in zones:
113
+ print(f"{zone.ZoneId}\t{zone.ZoneName}\thub={zone.HubId}\tappliances={len(zone.Appliances)}")
114
+ if args.verbose:
115
+ for app in zone.Appliances:
116
+ print(f" {app.ApplianceId}\t{app.FriendlyName}\t{app.ApplianceModel or app.ApplianceType}")
117
+ return 0
118
+
119
+
120
+ async def cmd_appliances(client: DimplexControl, args: argparse.Namespace) -> int:
121
+ hubs = await client.get_hubs()
122
+ for hub in hubs:
123
+ if args.hub and hub.HubId != args.hub:
124
+ continue
125
+ zones = await client.get_hub_zones(hub.HubId)
126
+ for zone in zones:
127
+ for app in zone.Appliances:
128
+ print(
129
+ f"{app.ApplianceId}\t{app.FriendlyName}\t"
130
+ f"{app.ApplianceModel or app.ApplianceType}\t"
131
+ f"zone={zone.ZoneName}\thub={hub.HubId}"
132
+ )
133
+ return 0
134
+
135
+
136
+ async def cmd_status(client: DimplexControl, args: argparse.Namespace) -> int:
137
+ overview = await client.get_appliance_overview(args.hub, [args.appliance])
138
+ if not overview:
139
+ print("(no status — appliance offline or empty overview)")
140
+ return 0
141
+ status = overview[0]
142
+ print(json.dumps(status.model_dump(mode="json"), indent=2, default=str))
143
+ return 0
144
+
145
+
146
+ async def cmd_energy(client: DimplexControl, args: argparse.Namespace) -> int:
147
+ report = await client.get_tsi_energy_report(hub_id=args.hub, days_back=args.days)
148
+ summary: dict[str, Any] = {
149
+ "hub_id": report.HubId,
150
+ "appliances": {app_id: len(points or []) for app_id, points in (report.ApplianceTelemetryData or {}).items()},
151
+ }
152
+ print(json.dumps(summary, indent=2))
153
+ return 0
154
+
155
+
156
+ async def cmd_boost(client: DimplexControl, args: argparse.Namespace) -> int:
157
+ if not args.yes:
158
+ print("error: control commands require --yes", file=sys.stderr)
159
+ return 2
160
+ await client.set_boost(
161
+ args.hub,
162
+ [args.appliance],
163
+ temperature=args.temperature,
164
+ duration_minutes=args.minutes,
165
+ enable=not args.clear,
166
+ )
167
+ print("ok")
168
+ return 0
169
+
170
+
171
+ async def cmd_away(client: DimplexControl, args: argparse.Namespace) -> int:
172
+ if not args.yes:
173
+ print("error: control commands require --yes", file=sys.stderr)
174
+ return 2
175
+ await client.set_away(
176
+ args.hub,
177
+ [args.appliance],
178
+ temperature=args.temperature,
179
+ enable=not args.clear,
180
+ )
181
+ print("ok")
182
+ return 0
183
+
184
+
185
+ async def cmd_eco(client: DimplexControl, args: argparse.Namespace) -> int:
186
+ if not args.yes:
187
+ print("error: control commands require --yes", file=sys.stderr)
188
+ return 2
189
+ await client.set_eco_start(args.hub, [args.appliance], not args.clear)
190
+ print("ok")
191
+ return 0
192
+
193
+
194
+ def build_parser() -> argparse.ArgumentParser:
195
+ parser = argparse.ArgumentParser(
196
+ prog="dimplex",
197
+ description="Dimplex Control cloud CLI (debug / smoke tests)",
198
+ )
199
+ parser.add_argument(
200
+ "--tokens-file",
201
+ default=os.environ.get("DIMPLEX_TOKENS_FILE"),
202
+ help="JSON token file (default: env DIMPLEX_TOKENS_FILE or DIMPLEX_* vars)",
203
+ )
204
+ parser.add_argument(
205
+ "--show-tokens",
206
+ action="store_true",
207
+ help="Print full tokens (default: redacted)",
208
+ )
209
+ sub = parser.add_subparsers(dest="command", required=True)
210
+
211
+ p_login = sub.add_parser("login", help="Validate tokens and print redacted status")
212
+ p_login.set_defaults(func=cmd_login)
213
+
214
+ p_hubs = sub.add_parser("hubs", help="List hubs")
215
+ p_hubs.set_defaults(func=cmd_hubs)
216
+
217
+ p_zones = sub.add_parser("zones", help="List zones for a hub")
218
+ p_zones.add_argument("--hub", help="Hub id (default: first hub)")
219
+ p_zones.add_argument("-v", "--verbose", action="store_true")
220
+ p_zones.set_defaults(func=cmd_zones)
221
+
222
+ p_apps = sub.add_parser("appliances", help="List appliances")
223
+ p_apps.add_argument("--hub", help="Filter by hub id")
224
+ p_apps.set_defaults(func=cmd_appliances)
225
+
226
+ p_status = sub.add_parser("status", help="Appliance overview status")
227
+ p_status.add_argument("hub")
228
+ p_status.add_argument("appliance")
229
+ p_status.set_defaults(func=cmd_status)
230
+
231
+ p_energy = sub.add_parser("energy", help="Energy report summary (point counts only)")
232
+ p_energy.add_argument("hub")
233
+ p_energy.add_argument("--days", type=int, default=30)
234
+ p_energy.set_defaults(func=cmd_energy)
235
+
236
+ for name, help_text, defaults in (
237
+ ("boost", "Enable or clear boost (--yes required)", {"minutes": 60, "temperature": 25.0}),
238
+ ("away", "Enable or clear away (--yes required)", {"temperature": 16.0}),
239
+ ("eco", "Enable or clear EcoStart (--yes required)", {}),
240
+ ):
241
+ p = sub.add_parser(name, help=help_text)
242
+ p.add_argument("hub")
243
+ p.add_argument("appliance")
244
+ p.add_argument("--yes", action="store_true", help="Confirm control write")
245
+ p.add_argument("--clear", action="store_true", help="Disable the mode")
246
+ if "temperature" in defaults:
247
+ p.add_argument("--temperature", type=float, default=defaults["temperature"])
248
+ if "minutes" in defaults:
249
+ p.add_argument("--minutes", type=int, default=defaults["minutes"])
250
+ p.set_defaults(func={"boost": cmd_boost, "away": cmd_away, "eco": cmd_eco}[name])
251
+
252
+ return parser
253
+
254
+
255
+ def main(argv: list[str] | None = None) -> int:
256
+ parser = build_parser()
257
+ args = parser.parse_args(argv)
258
+
259
+ async def run(client: DimplexControl) -> int:
260
+ return await args.func(client, args)
261
+
262
+ return asyncio.run(_with_client(args, run))
263
+
264
+
265
+ if __name__ == "__main__":
266
+ raise SystemExit(main())
@@ -1,12 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import logging
5
+ import random
4
6
  from datetime import datetime, timedelta, timezone
5
7
  from typing import Any
6
8
 
7
9
  import aiohttp
8
10
 
9
11
  from .auth import AuthManager, TokenBundle
12
+ from .capabilities import ApplianceCapabilities, capabilities_for
10
13
  from .const import (
11
14
  BASE_URL,
12
15
  HEADER_APP_NAME,
@@ -20,6 +23,7 @@ from .const import (
20
23
  )
21
24
  from .exceptions import DimplexApiError, DimplexConnectionError
22
25
  from .models import (
26
+ Appliance,
23
27
  ApplianceModeFlag,
24
28
  ApplianceModeSettings,
25
29
  ApplianceStatus,
@@ -45,6 +49,12 @@ DEFAULT_TSI_INTERVAL = "01:00:00"
45
49
  # Default boost length when the caller does not specify one (minutes).
46
50
  DEFAULT_BOOST_MINUTES = 60
47
51
 
52
+ # HTTP retry policy (see ``DimplexControl`` constructor).
53
+ DEFAULT_MAX_RETRIES = 3
54
+ DEFAULT_RETRY_BASE_DELAY = 0.5
55
+ DEFAULT_RETRY_MAX_DELAY = 8.0
56
+ _RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
57
+
48
58
 
49
59
  def _iso_utc_days_ago(days: int) -> str:
50
60
  """Return an ISO-8601 UTC timestamp ``days`` before now (no microseconds)."""
@@ -63,11 +73,26 @@ class DimplexControl:
63
73
  expires_at: float = 0,
64
74
  *,
65
75
  token_bundle: TokenBundle | None = None,
76
+ max_retries: int = DEFAULT_MAX_RETRIES,
77
+ retry_base_delay: float = DEFAULT_RETRY_BASE_DELAY,
78
+ retry_max_delay: float = DEFAULT_RETRY_MAX_DELAY,
79
+ retry_non_idempotent: bool = False,
66
80
  ):
67
81
  """Initialize the client.
68
82
 
69
83
  Prefer ``token_bundle`` for new code. The individual token kwargs remain
70
84
  supported for backwards compatibility.
85
+
86
+ Retry policy (centralised on ``_request``):
87
+
88
+ * **GET** (and other safe methods): retry on connection errors and
89
+ HTTP 429/5xx with exponential backoff + jitter; honour ``Retry-After``
90
+ when present.
91
+ * **POST/PUT/PATCH/DELETE**: no retries by default (non-idempotent
92
+ control calls). Set ``retry_non_idempotent=True`` to apply the same
93
+ policy (use with care).
94
+ * ``max_retries`` is the number of *retries* after the first attempt
95
+ (0 disables retries).
71
96
  """
72
97
  if token_bundle is not None:
73
98
  token_data: dict[str, Any] | TokenBundle = token_bundle
@@ -82,6 +107,10 @@ class DimplexControl:
82
107
 
83
108
  self._session = session
84
109
  self.auth = AuthManager(session, token_data)
110
+ self._max_retries = max(0, int(max_retries))
111
+ self._retry_base_delay = float(retry_base_delay)
112
+ self._retry_max_delay = float(retry_max_delay)
113
+ self._retry_non_idempotent = bool(retry_non_idempotent)
85
114
 
86
115
  @property
87
116
  def is_authenticated(self) -> bool:
@@ -96,8 +125,31 @@ class DimplexControl:
96
125
  """Replace in-memory auth tokens."""
97
126
  self.auth.apply_tokens(bundle)
98
127
 
128
+ def _should_retry(self, method: str) -> bool:
129
+ upper = method.upper()
130
+ if upper in {"GET", "HEAD", "OPTIONS"}:
131
+ return True
132
+ return self._retry_non_idempotent
133
+
134
+ def _backoff_seconds(self, attempt: int, retry_after: float | None = None) -> float:
135
+ """Compute delay before the next attempt (``attempt`` is 0-based)."""
136
+ if retry_after is not None and retry_after >= 0:
137
+ return min(retry_after, self._retry_max_delay)
138
+ # Exponential backoff with full jitter: U(0, min(max, base * 2^attempt))
139
+ ceiling = min(self._retry_max_delay, self._retry_base_delay * (2**attempt))
140
+ return random.uniform(0, ceiling)
141
+
142
+ @staticmethod
143
+ def _parse_retry_after(header_value: str | None) -> float | None:
144
+ if not header_value:
145
+ return None
146
+ try:
147
+ return max(0.0, float(header_value.strip()))
148
+ except ValueError:
149
+ return None
150
+
99
151
  async def _request(self, method: str, endpoint: str, **kwargs: Any) -> Any:
100
- """Make an authenticated request."""
152
+ """Make an authenticated request with optional retry/backoff."""
101
153
  token = await self.auth.get_access_token()
102
154
  headers = kwargs.pop("headers", {})
103
155
  headers.update(
@@ -118,20 +170,70 @@ class DimplexControl:
118
170
  )
119
171
 
120
172
  url = f"{BASE_URL}{endpoint}"
121
- try:
122
- async with self._session.request(method, url, headers=headers, **kwargs) as resp:
123
- if resp.status != HTTP_OK:
173
+ allow_retry = self._should_retry(method)
174
+ attempts = self._max_retries + 1 if allow_retry else 1
175
+ last_error: Exception | None = None
176
+
177
+ for attempt in range(attempts):
178
+ try:
179
+ async with self._session.request(method, url, headers=headers, **kwargs) as resp:
180
+ if resp.status == HTTP_OK:
181
+ if resp.content_length == 0:
182
+ return {}
183
+ return await resp.json()
184
+
124
185
  text = await resp.text()
186
+ retry_after = self._parse_retry_after(resp.headers.get("Retry-After"))
187
+ if allow_retry and resp.status in _RETRYABLE_STATUS and attempt + 1 < attempts:
188
+ delay = self._backoff_seconds(attempt, retry_after)
189
+ _LOGGER.warning(
190
+ "API %s %s failed with %s; retry %s/%s in %.2fs",
191
+ method,
192
+ endpoint,
193
+ resp.status,
194
+ attempt + 1,
195
+ self._max_retries,
196
+ delay,
197
+ )
198
+ last_error = DimplexApiError(resp.status, text)
199
+ await asyncio.sleep(delay)
200
+ continue
201
+
125
202
  _LOGGER.error("API request failed: %s - %s", resp.status, text)
126
203
  raise DimplexApiError(resp.status, text)
127
-
128
- # API might return empty body for some calls
129
- if resp.content_length == 0:
130
- return {}
131
- return await resp.json()
132
- except aiohttp.ClientError as e:
133
- _LOGGER.error("Connection error during API request: %s", e)
134
- raise DimplexConnectionError(f"Connection error: {e}") from e
204
+ except aiohttp.ClientError as e:
205
+ if allow_retry and attempt + 1 < attempts:
206
+ delay = self._backoff_seconds(attempt)
207
+ _LOGGER.warning(
208
+ "Connection error on %s %s; retry %s/%s in %.2fs: %s",
209
+ method,
210
+ endpoint,
211
+ attempt + 1,
212
+ self._max_retries,
213
+ delay,
214
+ e,
215
+ )
216
+ last_error = DimplexConnectionError(f"Connection error: {e}")
217
+ await asyncio.sleep(delay)
218
+ continue
219
+ _LOGGER.error("Connection error during API request: %s", e)
220
+ raise DimplexConnectionError(f"Connection error: {e}") from e
221
+
222
+ if isinstance(last_error, DimplexApiError):
223
+ raise last_error
224
+ if isinstance(last_error, DimplexConnectionError):
225
+ raise last_error
226
+ raise DimplexConnectionError("Request failed after retries")
227
+
228
+ @staticmethod
229
+ def capabilities_for(
230
+ appliance: Appliance | None = None,
231
+ *,
232
+ status: ApplianceStatus | None = None,
233
+ product: ProductModel | None = None,
234
+ ) -> ApplianceCapabilities:
235
+ """Return a capability matrix for an appliance (see :mod:`.capabilities`)."""
236
+ return capabilities_for(appliance, status=status, product=product)
135
237
 
136
238
  async def get_hubs(self) -> list[Hub]:
137
239
  """Get all hubs for the user."""
@@ -195,6 +297,16 @@ class DimplexControl:
195
297
  data = await self._request("POST", "/RemoteControl/GetTimerModeDetailsForAppliance", json=payload)
196
298
  return TimerModeSettings.model_validate(data)
197
299
 
300
+ async def get_schedule(self, hub_id: str, appliance_id: str) -> TimerModeSettings:
301
+ """Return the current timer mode + periods (alias of :meth:`get_appliance_features`)."""
302
+ return await self.get_appliance_features(hub_id, appliance_id)
303
+
304
+ async def _write_timer_settings(self, settings: TimerModeSettings) -> TimerModeSettings:
305
+ """POST a full :class:`TimerModeSettings` payload and return it."""
306
+ payload = {"TimerModeSettings": settings.model_dump(mode="json")}
307
+ await self._request("POST", "/RemoteControl/SetTimerMode", json=payload)
308
+ return settings
309
+
198
310
  async def set_mode(self, hub_id: str, appliance_id: str, mode: int | TimerMode) -> None:
199
311
  """Set the timer / operation mode.
200
312
 
@@ -202,9 +314,56 @@ class DimplexControl:
202
314
  """
203
315
  current = await self.get_appliance_features(hub_id, appliance_id)
204
316
  current.TimerMode = int(mode)
317
+ await self._write_timer_settings(current)
205
318
 
206
- payload = {"TimerModeSettings": current.model_dump(mode="json")}
207
- await self._request("POST", "/RemoteControl/SetTimerMode", json=payload)
319
+ async def set_period_setpoint(
320
+ self,
321
+ hub_id: str,
322
+ appliance_id: str,
323
+ *,
324
+ day_of_week: int,
325
+ start_time: str,
326
+ temperature: float,
327
+ end_time: str | None = None,
328
+ ) -> TimerModeSettings:
329
+ """Update one timer period's setpoint (and optional end) without clobbering others.
330
+
331
+ Matches periods by ``DayOfWeek`` + ``StartTime``. Raises ``ValueError`` if
332
+ no period matches. Prefer this over rewriting the whole schedule.
333
+ """
334
+ current = await self.get_appliance_features(hub_id, appliance_id)
335
+ matched = False
336
+ for period in current.TimerPeriods:
337
+ if period.DayOfWeek == day_of_week and period.StartTime == start_time:
338
+ period.Temperature = float(temperature)
339
+ if end_time is not None:
340
+ period.EndTime = end_time
341
+ matched = True
342
+ break
343
+ if not matched:
344
+ raise ValueError(f"No timer period for day={day_of_week} start={start_time!r} on appliance {appliance_id}")
345
+ return await self._write_timer_settings(current)
346
+
347
+ async def update_period(
348
+ self,
349
+ hub_id: str,
350
+ appliance_id: str,
351
+ period: TimerPeriod,
352
+ *,
353
+ match_start_time: str | None = None,
354
+ ) -> TimerModeSettings:
355
+ """Replace a single period matched by day + start time (read-modify-write).
356
+
357
+ ``match_start_time`` defaults to ``period.StartTime`` so callers can also
358
+ change the period's start by passing the previous start string.
359
+ """
360
+ key_start = match_start_time if match_start_time is not None else period.StartTime
361
+ current = await self.get_appliance_features(hub_id, appliance_id)
362
+ for index, existing in enumerate(current.TimerPeriods):
363
+ if existing.DayOfWeek == period.DayOfWeek and existing.StartTime == key_start:
364
+ current.TimerPeriods[index] = period
365
+ return await self._write_timer_settings(current)
366
+ raise ValueError(f"No timer period for day={period.DayOfWeek} start={key_start!r} on appliance {appliance_id}")
208
367
 
209
368
  async def set_target_temperature(self, hub_id: str, appliance_id: str, temp: float) -> None:
210
369
  """Set the target / comfort temperature for an appliance.
@@ -219,6 +378,10 @@ class DimplexControl:
219
378
 
220
379
  This matches the reverse-engineered mobile-app approach of rewriting
221
380
  the active schedule rather than a dedicated "set temperature" RPC.
381
+
382
+ **Note:** unlike :meth:`set_period_setpoint`, this updates *all* period
383
+ temperatures (or installs a full-week schedule). Use the period helpers
384
+ when only one window should change.
222
385
  """
223
386
  current = await self.get_appliance_features(hub_id, appliance_id)
224
387
 
@@ -237,8 +400,7 @@ class DimplexControl:
237
400
  for day in range(7)
238
401
  ]
239
402
 
240
- payload = {"TimerModeSettings": current.model_dump(mode="json")}
241
- await self._request("POST", "/RemoteControl/SetTimerMode", json=payload)
403
+ await self._write_timer_settings(current)
242
404
 
243
405
  async def set_appliance_mode(
244
406
  self, hub_id: str, appliance_ids: list[str], mode_settings: ApplianceModeSettings
@@ -339,6 +501,13 @@ class DimplexControl:
339
501
  Note: with ``include_previous_period=True`` (the default) the cloud
340
502
  frequently returns the **full available daily history**, not only the
341
503
  ``days_back`` window. Filter client-side for daily/lifetime totals.
504
+
505
+ Points may include both ``T1`` (off-peak / cheaper) and ``T2``
506
+ (peak / more expensive). Parse them with
507
+ :data:`~dimplex_controller.telemetry.VALUE_KEY_T1` and
508
+ :data:`~dimplex_controller.telemetry.VALUE_KEY_T2` separately —
509
+ never sum T1+T2 into a single total.
510
+
342
511
  """
343
512
  if start_date is None:
344
513
  start_date = _iso_utc_days_ago(days_back)
@@ -7,7 +7,17 @@ and varies between firmware versions, so we normalise whatever the cloud
7
7
  sends into ``(timestamp, value)`` tuples.
8
8
 
9
9
  Real-world payloads have been observed using ``TS`` (Unix-epoch timestamp)
10
- with either ``T1`` or ``ST`` as the energy value key.
10
+ with either ``T1`` or ``ST`` as the energy value key. Some appliances also
11
+ report a secondary register ``T2`` on the same point.
12
+
13
+ **T1 and T2 must stay separate.** They are dual-rate tariff registers, not
14
+ dual samples of the same meter:
15
+
16
+ * **T1** — off-peak (cheaper rate)
17
+ * **T2** — peak (more expensive rate)
18
+
19
+ Never sum T1+T2 into a single "total energy" figure, and never fall back from
20
+ T1 parsing to T2 values (or vice versa).
11
21
 
12
22
  Important cloud semantics (live-verified):
13
23
 
@@ -18,10 +28,10 @@ Important cloud semantics (live-verified):
18
28
  * Points are typically **one kWh sample per calendar day**, not a continuous
19
29
  cumulative counter.
20
30
 
21
- Use :func:`summarise_energy` for the two product totals:
31
+ Use :func:`summarise_energy` for the two product totals **per register**:
22
32
 
23
33
  * ``daily`` — kWh for the local calendar day (from local midnight)
24
- * ``lifetime`` — sum of all parsed points (earliest latest)
34
+ * ``lifetime`` — sum of all parsed points for that register only
25
35
  """
26
36
 
27
37
  from __future__ import annotations
@@ -44,7 +54,10 @@ _DEFAULT_TIMESTAMP_KEYS = (
44
54
  "from",
45
55
  "start",
46
56
  )
47
- _DEFAULT_VALUE_KEYS = (
57
+
58
+ # Primary / off-peak energy register (T1 / single-register appliances).
59
+ # Intentionally excludes ``t2`` so off-peak and peak series are never mixed.
60
+ VALUE_KEY_T1 = (
48
61
  "t1",
49
62
  "st", # Observed on QRAD050F / QRAD075F energy reports (see dimplex-controller-hass #27).
50
63
  "value",
@@ -54,12 +67,11 @@ _DEFAULT_VALUE_KEYS = (
54
67
  "energykwh",
55
68
  "amount",
56
69
  "v",
57
- # Fallback for appliances that only report the secondary register.
58
- "t2",
59
70
  )
60
71
 
61
- # Secondary energy register observed for some Quantum appliances. Points may
62
- # contain both ``T1`` and ``T2`` in the same payload.
72
+ # Secondary / peak energy register (T2) observed for some Quantum appliances.
73
+ # Points may contain both ``T1`` and ``T2`` in the same payload — parse each
74
+ # with its own key list; do not combine.
63
75
  VALUE_KEY_T2 = (
64
76
  "t2",
65
77
  "value2",
@@ -67,6 +79,9 @@ VALUE_KEY_T2 = (
67
79
  "energy2",
68
80
  )
69
81
 
82
+ # Backwards-compatible alias for the primary register key list.
83
+ _DEFAULT_VALUE_KEYS = VALUE_KEY_T1
84
+
70
85
  EnergyMode = Literal["daily", "lifetime", "window"]
71
86
  TelemetryPoint = tuple[datetime | None, float]
72
87
 
@@ -135,7 +150,7 @@ def _coerce_value(raw: Any) -> float | None:
135
150
  return None
136
151
 
137
152
 
138
- def _iter_items(point: Any) -> Iterable[tuple[str, Any]] | None:
153
+ def _iter_items(point: Any, value_keys: tuple[str, ...]) -> Iterable[tuple[str, Any]] | None:
139
154
  """Yield ``(key, value)`` pairs from a dict-like ``point``.
140
155
 
141
156
  Returns ``None`` for non-dict points so the caller can fall through to the
@@ -144,7 +159,7 @@ def _iter_items(point: Any) -> Iterable[tuple[str, Any]] | None:
144
159
  if not isinstance(point, dict):
145
160
  return None
146
161
  lower = {str(k).lower(): v for k, v in point.items()}
147
- return ((k, lower.get(k)) for k in (*_DEFAULT_TIMESTAMP_KEYS, *_DEFAULT_VALUE_KEYS, *VALUE_KEY_T2))
162
+ return ((k, lower.get(k)) for k in (*_DEFAULT_TIMESTAMP_KEYS, *value_keys))
148
163
 
149
164
 
150
165
  def parse_telemetry_points(
@@ -160,9 +175,10 @@ def parse_telemetry_points(
160
175
  * a 2-element ``[timestamp, value]`` list or tuple
161
176
  * a bare scalar (treated as a cumulative value at an unknown timestamp)
162
177
 
163
- ``value_keys`` overrides the value-key priority list. Use
164
- :data:`VALUE_KEY_T2` to extract the secondary energy register when the
165
- cloud returns both ``T1`` and ``T2`` in the same payload.
178
+ ``value_keys`` overrides the value-key priority list. Defaults to
179
+ :data:`VALUE_KEY_T1` (primary register only). Use :data:`VALUE_KEY_T2` to
180
+ extract the secondary register. T1 and T2 must be parsed separately —
181
+ never pass a key list that mixes both if you need tariff-accurate totals.
166
182
 
167
183
  Unparseable entries are silently skipped. The order of the input list is
168
184
  preserved.
@@ -170,7 +186,7 @@ def parse_telemetry_points(
170
186
  if not isinstance(points, list):
171
187
  return []
172
188
 
173
- value_key_order = value_keys if value_keys is not None else _DEFAULT_VALUE_KEYS
189
+ value_key_order = value_keys if value_keys is not None else VALUE_KEY_T1
174
190
  timestamp_keys = _DEFAULT_TIMESTAMP_KEYS
175
191
 
176
192
  out: list[TelemetryPoint] = []
@@ -178,7 +194,7 @@ def parse_telemetry_points(
178
194
  ts: datetime | None = None
179
195
  value: float | None = None
180
196
 
181
- items = _iter_items(point)
197
+ items = _iter_items(point, value_key_order)
182
198
  if items is not None:
183
199
  for key, raw in items:
184
200
  if key in timestamp_keys and ts is None:
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "dimplex-controller"
3
- version = "0.9.0"
3
+ version = "0.10.1"
4
4
  description = "Python client for Dimplex heating controllers (GDHV IoT)"
5
5
  authors = ["Kieran Roper"]
6
6
  license = "MIT"
@@ -23,6 +23,9 @@ classifiers = [
23
23
  ]
24
24
  packages = [{ include = "dimplex_controller" }]
25
25
 
26
+ [tool.poetry.scripts]
27
+ dimplex = "dimplex_controller.cli:main"
28
+
26
29
  [tool.poetry.dependencies]
27
30
  python = "^3.10"
28
31
  aiohttp = "^3.9.0"