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.
- {dimplex_controller-0.9.0 → dimplex_controller-0.10.1}/PKG-INFO +41 -2
- {dimplex_controller-0.9.0 → dimplex_controller-0.10.1}/README.md +40 -1
- {dimplex_controller-0.9.0 → dimplex_controller-0.10.1}/dimplex_controller/__init__.py +17 -1
- dimplex_controller-0.10.1/dimplex_controller/capabilities.py +178 -0
- dimplex_controller-0.10.1/dimplex_controller/cli.py +266 -0
- {dimplex_controller-0.9.0 → dimplex_controller-0.10.1}/dimplex_controller/client.py +185 -16
- {dimplex_controller-0.9.0 → dimplex_controller-0.10.1}/dimplex_controller/telemetry.py +31 -15
- {dimplex_controller-0.9.0 → dimplex_controller-0.10.1}/pyproject.toml +4 -1
- {dimplex_controller-0.9.0 → dimplex_controller-0.10.1}/LICENSE +0 -0
- {dimplex_controller-0.9.0 → dimplex_controller-0.10.1}/dimplex_controller/auth.py +0 -0
- {dimplex_controller-0.9.0 → dimplex_controller-0.10.1}/dimplex_controller/const.py +0 -0
- {dimplex_controller-0.9.0 → dimplex_controller-0.10.1}/dimplex_controller/exceptions.py +0 -0
- {dimplex_controller-0.9.0 → dimplex_controller-0.10.1}/dimplex_controller/models.py +0 -0
- {dimplex_controller-0.9.0 → dimplex_controller-0.10.1}/dimplex_controller/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dimplex-controller
|
|
3
|
-
Version: 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
|
|
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
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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, *
|
|
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.
|
|
164
|
-
:data:`
|
|
165
|
-
|
|
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
|
|
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.
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|