dimplex-controller 0.6.1__tar.gz → 0.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dimplex_controller-0.6.1 → dimplex_controller-0.8.0}/PKG-INFO +12 -10
- {dimplex_controller-0.6.1 → dimplex_controller-0.8.0}/README.md +11 -8
- {dimplex_controller-0.6.1 → dimplex_controller-0.8.0}/dimplex_controller/__init__.py +22 -1
- {dimplex_controller-0.6.1 → dimplex_controller-0.8.0}/dimplex_controller/auth.py +101 -32
- {dimplex_controller-0.6.1 → dimplex_controller-0.8.0}/dimplex_controller/client.py +173 -43
- {dimplex_controller-0.6.1 → dimplex_controller-0.8.0}/dimplex_controller/models.py +93 -2
- dimplex_controller-0.8.0/dimplex_controller/py.typed +0 -0
- dimplex_controller-0.8.0/dimplex_controller/telemetry.py +304 -0
- {dimplex_controller-0.6.1 → dimplex_controller-0.8.0}/pyproject.toml +4 -2
- dimplex_controller-0.6.1/dimplex_controller/telemetry.py +0 -160
- {dimplex_controller-0.6.1 → dimplex_controller-0.8.0}/LICENSE +0 -0
- {dimplex_controller-0.6.1 → dimplex_controller-0.8.0}/dimplex_controller/const.py +0 -0
- {dimplex_controller-0.6.1 → dimplex_controller-0.8.0}/dimplex_controller/exceptions.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dimplex-controller
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Python client for Dimplex heating controllers (GDHV IoT)
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -22,7 +22,6 @@ Classifier: Topic :: Home Automation
|
|
|
22
22
|
Requires-Dist: aiohttp (>=3.9.0,<4.0.0)
|
|
23
23
|
Requires-Dist: beautifulsoup4 (>=4.14.3,<5.0.0)
|
|
24
24
|
Requires-Dist: pydantic (>=2.0.0,<3.0.0)
|
|
25
|
-
Requires-Dist: python-dotenv (>=1.2.1,<2.0.0)
|
|
26
25
|
Project-URL: Homepage, https://github.com/KRoperUK/dimplex-controller-py
|
|
27
26
|
Project-URL: Repository, https://github.com/KRoperUK/dimplex-controller-py
|
|
28
27
|
Description-Content-Type: text/markdown
|
|
@@ -179,24 +178,27 @@ await client.set_eco_start(hub_id, [appliance_id], True)
|
|
|
179
178
|
# Enable Open Window Detection
|
|
180
179
|
await client.set_open_window_detection(hub_id, [appliance_id], True)
|
|
181
180
|
|
|
182
|
-
# Activate Boost
|
|
183
|
-
|
|
184
|
-
|
|
181
|
+
# Activate Boost
|
|
182
|
+
await client.set_boost(hub_id, [appliance_id], temperature=25.0, duration_minutes=60)
|
|
183
|
+
|
|
184
|
+
# Set target temperature (rewrites timer period setpoints)
|
|
185
|
+
await client.set_target_temperature(hub_id, appliance_id, 21.5)
|
|
185
186
|
```
|
|
186
187
|
|
|
187
188
|
### Energy reports
|
|
188
189
|
|
|
189
190
|
```python
|
|
190
|
-
from dimplex_controller import parse_telemetry_points
|
|
191
|
+
from dimplex_controller import parse_telemetry_points, summarise_energy
|
|
191
192
|
|
|
192
193
|
report = await client.get_tsi_energy_report(hub_id)
|
|
193
|
-
for appliance_id, telemetry in report.
|
|
194
|
+
for appliance_id, telemetry in report.ApplianceTelemetryData.items():
|
|
194
195
|
points = parse_telemetry_points(telemetry)
|
|
195
|
-
|
|
196
|
-
|
|
196
|
+
daily = summarise_energy(points, mode="daily")
|
|
197
|
+
lifetime = summarise_energy(points, mode="lifetime")
|
|
198
|
+
print(f"{appliance_id}: today={daily.total_kwh} kWh, lifetime={lifetime.total_kwh} kWh")
|
|
197
199
|
```
|
|
198
200
|
|
|
199
|
-
|
|
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.
|
|
200
202
|
|
|
201
203
|
## Configuration
|
|
202
204
|
|
|
@@ -150,24 +150,27 @@ await client.set_eco_start(hub_id, [appliance_id], True)
|
|
|
150
150
|
# Enable Open Window Detection
|
|
151
151
|
await client.set_open_window_detection(hub_id, [appliance_id], True)
|
|
152
152
|
|
|
153
|
-
# Activate Boost
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
# Activate Boost
|
|
154
|
+
await client.set_boost(hub_id, [appliance_id], temperature=25.0, duration_minutes=60)
|
|
155
|
+
|
|
156
|
+
# Set target temperature (rewrites timer period setpoints)
|
|
157
|
+
await client.set_target_temperature(hub_id, appliance_id, 21.5)
|
|
156
158
|
```
|
|
157
159
|
|
|
158
160
|
### Energy reports
|
|
159
161
|
|
|
160
162
|
```python
|
|
161
|
-
from dimplex_controller import parse_telemetry_points
|
|
163
|
+
from dimplex_controller import parse_telemetry_points, summarise_energy
|
|
162
164
|
|
|
163
165
|
report = await client.get_tsi_energy_report(hub_id)
|
|
164
|
-
for appliance_id, telemetry in report.
|
|
166
|
+
for appliance_id, telemetry in report.ApplianceTelemetryData.items():
|
|
165
167
|
points = parse_telemetry_points(telemetry)
|
|
166
|
-
|
|
167
|
-
|
|
168
|
+
daily = summarise_energy(points, mode="daily")
|
|
169
|
+
lifetime = summarise_energy(points, mode="lifetime")
|
|
170
|
+
print(f"{appliance_id}: today={daily.total_kwh} kWh, lifetime={lifetime.total_kwh} kWh")
|
|
168
171
|
```
|
|
169
172
|
|
|
170
|
-
|
|
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.
|
|
171
174
|
|
|
172
175
|
## Configuration
|
|
173
176
|
|
|
@@ -1,28 +1,49 @@
|
|
|
1
1
|
"""Dimplex Controller Client."""
|
|
2
2
|
|
|
3
|
+
from .auth import TokenBundle
|
|
3
4
|
from .client import DimplexControl
|
|
4
5
|
from .exceptions import DimplexApiError, DimplexAuthError, DimplexConnectionError, DimplexError
|
|
5
6
|
from .models import (
|
|
6
7
|
Appliance,
|
|
8
|
+
ApplianceModeFlag,
|
|
7
9
|
ApplianceModeSettings,
|
|
8
10
|
ApplianceStatus,
|
|
9
11
|
AutomaticProvisioning,
|
|
10
12
|
Hub,
|
|
13
|
+
ProductModel,
|
|
14
|
+
TimerMode,
|
|
15
|
+
TimerModeSettings,
|
|
16
|
+
TimerPeriod,
|
|
11
17
|
TsiEnergyReport,
|
|
12
18
|
Zone,
|
|
13
19
|
)
|
|
14
|
-
from .telemetry import
|
|
20
|
+
from .telemetry import (
|
|
21
|
+
VALUE_KEY_T2,
|
|
22
|
+
EnergySummary,
|
|
23
|
+
filter_telemetry_points,
|
|
24
|
+
parse_telemetry_points,
|
|
25
|
+
summarise_energy,
|
|
26
|
+
)
|
|
15
27
|
|
|
16
28
|
__all__ = [
|
|
17
29
|
"DimplexControl",
|
|
30
|
+
"TokenBundle",
|
|
18
31
|
"Hub",
|
|
19
32
|
"Zone",
|
|
20
33
|
"Appliance",
|
|
21
34
|
"ApplianceStatus",
|
|
22
35
|
"ApplianceModeSettings",
|
|
36
|
+
"ApplianceModeFlag",
|
|
37
|
+
"TimerMode",
|
|
38
|
+
"TimerModeSettings",
|
|
39
|
+
"TimerPeriod",
|
|
23
40
|
"AutomaticProvisioning",
|
|
41
|
+
"ProductModel",
|
|
24
42
|
"TsiEnergyReport",
|
|
25
43
|
"parse_telemetry_points",
|
|
44
|
+
"filter_telemetry_points",
|
|
45
|
+
"summarise_energy",
|
|
46
|
+
"EnergySummary",
|
|
26
47
|
"VALUE_KEY_T2",
|
|
27
48
|
"DimplexError",
|
|
28
49
|
"DimplexApiError",
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import json
|
|
2
4
|
import logging
|
|
3
5
|
import os
|
|
4
6
|
import re
|
|
5
7
|
import time
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
6
11
|
from urllib.parse import parse_qs, urlparse
|
|
7
12
|
|
|
8
13
|
import aiohttp
|
|
@@ -19,33 +24,90 @@ from .exceptions import DimplexAuthError
|
|
|
19
24
|
|
|
20
25
|
_LOGGER = logging.getLogger(__name__)
|
|
21
26
|
|
|
27
|
+
TokenListener = Callable[["TokenBundle"], Awaitable[None] | None]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class TokenBundle:
|
|
32
|
+
"""Public, serialisable snapshot of auth tokens."""
|
|
33
|
+
|
|
34
|
+
access_token: str | None = None
|
|
35
|
+
refresh_token: str | None = None
|
|
36
|
+
expires_at: float = 0.0
|
|
37
|
+
|
|
38
|
+
def as_dict(self) -> dict[str, Any]:
|
|
39
|
+
"""Return a plain dict suitable for JSON / config-entry storage."""
|
|
40
|
+
return {
|
|
41
|
+
"access_token": self.access_token,
|
|
42
|
+
"refresh_token": self.refresh_token,
|
|
43
|
+
"expires_at": self.expires_at,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_mapping(cls, data: dict[str, Any] | None) -> TokenBundle:
|
|
48
|
+
"""Build a bundle from a dict-like token store."""
|
|
49
|
+
if not data:
|
|
50
|
+
return cls()
|
|
51
|
+
return cls(
|
|
52
|
+
access_token=data.get("access_token"),
|
|
53
|
+
refresh_token=data.get("refresh_token"),
|
|
54
|
+
expires_at=float(data.get("expires_at") or 0),
|
|
55
|
+
)
|
|
56
|
+
|
|
22
57
|
|
|
23
58
|
class AuthManager:
|
|
24
59
|
"""Manages authentication for Dimplex Control."""
|
|
25
60
|
|
|
26
|
-
def __init__(
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
session: aiohttp.ClientSession,
|
|
64
|
+
token_data: dict[str, Any] | TokenBundle | None = None,
|
|
65
|
+
*,
|
|
66
|
+
on_token_update: TokenListener | None = None,
|
|
67
|
+
):
|
|
27
68
|
"""Initialize the auth manager."""
|
|
28
69
|
self._session = session
|
|
29
|
-
self.
|
|
30
|
-
|
|
31
|
-
self.
|
|
70
|
+
self._on_token_update = on_token_update
|
|
71
|
+
bundle = token_data if isinstance(token_data, TokenBundle) else TokenBundle.from_mapping(token_data)
|
|
72
|
+
self._access_token: str | None = bundle.access_token
|
|
73
|
+
self._refresh_token: str | None = bundle.refresh_token
|
|
74
|
+
self._expires_at: float = bundle.expires_at
|
|
32
75
|
|
|
33
76
|
@property
|
|
34
77
|
def is_authenticated(self) -> bool:
|
|
35
78
|
"""Check if we have a valid access token."""
|
|
36
79
|
return self._access_token is not None and time.time() < self._expires_at
|
|
37
80
|
|
|
81
|
+
def export_tokens(self) -> TokenBundle:
|
|
82
|
+
"""Return the current token snapshot (safe for persistence)."""
|
|
83
|
+
return TokenBundle(
|
|
84
|
+
access_token=self._access_token,
|
|
85
|
+
refresh_token=self._refresh_token,
|
|
86
|
+
expires_at=self._expires_at,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def apply_tokens(self, bundle: TokenBundle | dict[str, Any]) -> None:
|
|
90
|
+
"""Replace in-memory tokens from a :class:`TokenBundle` or dict."""
|
|
91
|
+
if not isinstance(bundle, TokenBundle):
|
|
92
|
+
bundle = TokenBundle.from_mapping(bundle)
|
|
93
|
+
self._access_token = bundle.access_token
|
|
94
|
+
self._refresh_token = bundle.refresh_token
|
|
95
|
+
self._expires_at = bundle.expires_at
|
|
96
|
+
|
|
38
97
|
async def get_access_token(self) -> str:
|
|
39
98
|
"""Get a valid access token, refreshing if necessary."""
|
|
40
99
|
if not self._refresh_token:
|
|
41
100
|
raise DimplexAuthError("No refresh token available. User must authenticate first.")
|
|
42
101
|
|
|
43
102
|
if self.is_authenticated:
|
|
44
|
-
|
|
103
|
+
assert self._access_token is not None
|
|
104
|
+
return self._access_token
|
|
45
105
|
|
|
46
106
|
# Token expired or missing, try refresh
|
|
47
107
|
await self.refresh_tokens()
|
|
48
|
-
|
|
108
|
+
if not self._access_token:
|
|
109
|
+
raise DimplexAuthError("Token refresh succeeded but no access token was returned.")
|
|
110
|
+
return self._access_token
|
|
49
111
|
|
|
50
112
|
async def refresh_tokens(self) -> None:
|
|
51
113
|
"""Refresh the access token using the refresh token."""
|
|
@@ -67,12 +129,42 @@ class AuthManager:
|
|
|
67
129
|
data = await resp.json()
|
|
68
130
|
self._update_tokens(data)
|
|
69
131
|
|
|
70
|
-
def _update_tokens(self, data: dict) -> None:
|
|
132
|
+
def _update_tokens(self, data: dict[str, Any]) -> None:
|
|
71
133
|
"""Update internal token state from API response."""
|
|
72
134
|
self._access_token = data.get("access_token")
|
|
73
|
-
|
|
135
|
+
# Some refresh responses omit a new refresh token — keep the old one.
|
|
136
|
+
if data.get("refresh_token"):
|
|
137
|
+
self._refresh_token = data.get("refresh_token")
|
|
74
138
|
expires_in = data.get("expires_in", 3600)
|
|
75
|
-
self._expires_at = time.time() + expires_in - 60 # Buffer 60s
|
|
139
|
+
self._expires_at = time.time() + float(expires_in) - 60 # Buffer 60s
|
|
140
|
+
if self._on_token_update is not None:
|
|
141
|
+
result = self._on_token_update(self.export_tokens())
|
|
142
|
+
if hasattr(result, "__await__"):
|
|
143
|
+
# Listener may be async; fire-and-forget is unsafe here — callers
|
|
144
|
+
# should prefer sync listeners or schedule from the callback.
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
def save_tokens(self, file_path: str) -> None:
|
|
148
|
+
"""Save current tokens to a JSON file."""
|
|
149
|
+
data = self.export_tokens().as_dict()
|
|
150
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
151
|
+
json.dump(data, f, indent=2)
|
|
152
|
+
_LOGGER.info("Tokens saved to %s", file_path)
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def load_tokens(cls, file_path: str) -> dict[str, Any] | None:
|
|
156
|
+
"""Load tokens from a JSON file."""
|
|
157
|
+
if not os.path.exists(file_path):
|
|
158
|
+
return None
|
|
159
|
+
try:
|
|
160
|
+
with open(file_path, encoding="utf-8") as f:
|
|
161
|
+
data = json.load(f)
|
|
162
|
+
if isinstance(data, dict):
|
|
163
|
+
return data
|
|
164
|
+
return None
|
|
165
|
+
except Exception as e:
|
|
166
|
+
_LOGGER.error("Failed to load tokens from %s: %s", file_path, e)
|
|
167
|
+
return None
|
|
76
168
|
|
|
77
169
|
def get_login_url(self) -> str:
|
|
78
170
|
"""Generate the login URL for the user to visit."""
|
|
@@ -304,26 +396,3 @@ class AuthManager:
|
|
|
304
396
|
raise DimplexAuthError(f"Unexpected HTTP {resp.status} during redirect chain")
|
|
305
397
|
|
|
306
398
|
raise DimplexAuthError("Exceeded maximum redirect hops without capturing auth code")
|
|
307
|
-
|
|
308
|
-
def save_tokens(self, file_path: str) -> None:
|
|
309
|
-
"""Save current tokens to a JSON file."""
|
|
310
|
-
data = {
|
|
311
|
-
"access_token": self._access_token,
|
|
312
|
-
"refresh_token": self._refresh_token,
|
|
313
|
-
"expires_at": self._expires_at,
|
|
314
|
-
}
|
|
315
|
-
with open(file_path, "w") as f:
|
|
316
|
-
json.dump(data, f, indent=2)
|
|
317
|
-
_LOGGER.info("Tokens saved to %s", file_path)
|
|
318
|
-
|
|
319
|
-
@classmethod
|
|
320
|
-
def load_tokens(cls, file_path: str) -> dict | None:
|
|
321
|
-
"""Load tokens from a JSON file."""
|
|
322
|
-
if not os.path.exists(file_path):
|
|
323
|
-
return None
|
|
324
|
-
try:
|
|
325
|
-
with open(file_path) as f:
|
|
326
|
-
return json.load(f)
|
|
327
|
-
except Exception as e:
|
|
328
|
-
_LOGGER.error("Failed to load tokens from %s: %s", file_path, e)
|
|
329
|
-
return None
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import logging
|
|
2
4
|
from datetime import datetime, timedelta, timezone
|
|
3
5
|
from typing import Any
|
|
4
6
|
|
|
5
7
|
import aiohttp
|
|
6
8
|
|
|
7
|
-
from .auth import AuthManager
|
|
9
|
+
from .auth import AuthManager, TokenBundle
|
|
8
10
|
from .const import (
|
|
9
11
|
BASE_URL,
|
|
10
12
|
HEADER_APP_NAME,
|
|
@@ -18,10 +20,14 @@ from .const import (
|
|
|
18
20
|
)
|
|
19
21
|
from .exceptions import DimplexApiError, DimplexConnectionError
|
|
20
22
|
from .models import (
|
|
23
|
+
ApplianceModeFlag,
|
|
21
24
|
ApplianceModeSettings,
|
|
22
25
|
ApplianceStatus,
|
|
23
26
|
Hub,
|
|
27
|
+
ProductModel,
|
|
28
|
+
TimerMode,
|
|
24
29
|
TimerModeSettings,
|
|
30
|
+
TimerPeriod,
|
|
25
31
|
TsiEnergyReport,
|
|
26
32
|
UserContext,
|
|
27
33
|
Zone,
|
|
@@ -30,11 +36,15 @@ from .models import (
|
|
|
30
36
|
_LOGGER = logging.getLogger(__name__)
|
|
31
37
|
|
|
32
38
|
# Default lookback when the caller does not pin a start date. The energy
|
|
33
|
-
# endpoint is paginated server-side;
|
|
34
|
-
#
|
|
39
|
+
# endpoint is paginated server-side; with IncludePreviousPeriod the cloud
|
|
40
|
+
# often returns the full history regardless, so this mainly bounds the
|
|
41
|
+
# "current window" half of the request.
|
|
35
42
|
DEFAULT_TSI_REPORT_DAYS = 30
|
|
36
43
|
DEFAULT_TSI_INTERVAL = "01:00:00"
|
|
37
44
|
|
|
45
|
+
# Default boost length when the caller does not specify one (minutes).
|
|
46
|
+
DEFAULT_BOOST_MINUTES = 60
|
|
47
|
+
|
|
38
48
|
|
|
39
49
|
def _iso_utc_days_ago(days: int) -> str:
|
|
40
50
|
"""Return an ISO-8601 UTC timestamp ``days`` before now (no microseconds)."""
|
|
@@ -51,15 +61,24 @@ class DimplexControl:
|
|
|
51
61
|
refresh_token: str | None = None,
|
|
52
62
|
access_token: str | None = None,
|
|
53
63
|
expires_at: float = 0,
|
|
64
|
+
*,
|
|
65
|
+
token_bundle: TokenBundle | None = None,
|
|
54
66
|
):
|
|
55
|
-
"""Initialize the client.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
"""Initialize the client.
|
|
68
|
+
|
|
69
|
+
Prefer ``token_bundle`` for new code. The individual token kwargs remain
|
|
70
|
+
supported for backwards compatibility.
|
|
71
|
+
"""
|
|
72
|
+
if token_bundle is not None:
|
|
73
|
+
token_data: dict[str, Any] | TokenBundle = token_bundle
|
|
74
|
+
else:
|
|
75
|
+
token_data = {}
|
|
76
|
+
if refresh_token:
|
|
77
|
+
token_data["refresh_token"] = refresh_token
|
|
78
|
+
if access_token:
|
|
79
|
+
token_data["access_token"] = access_token
|
|
80
|
+
if expires_at:
|
|
81
|
+
token_data["expires_at"] = expires_at
|
|
63
82
|
|
|
64
83
|
self._session = session
|
|
65
84
|
self.auth = AuthManager(session, token_data)
|
|
@@ -69,7 +88,15 @@ class DimplexControl:
|
|
|
69
88
|
"""Check if authenticated."""
|
|
70
89
|
return self.auth.is_authenticated
|
|
71
90
|
|
|
72
|
-
|
|
91
|
+
def export_tokens(self) -> TokenBundle:
|
|
92
|
+
"""Return the current auth token snapshot."""
|
|
93
|
+
return self.auth.export_tokens()
|
|
94
|
+
|
|
95
|
+
def apply_tokens(self, bundle: TokenBundle | dict[str, Any]) -> None:
|
|
96
|
+
"""Replace in-memory auth tokens."""
|
|
97
|
+
self.auth.apply_tokens(bundle)
|
|
98
|
+
|
|
99
|
+
async def _request(self, method: str, endpoint: str, **kwargs: Any) -> Any:
|
|
73
100
|
"""Make an authenticated request."""
|
|
74
101
|
token = await self.auth.get_access_token()
|
|
75
102
|
headers = kwargs.pop("headers", {})
|
|
@@ -109,76 +136,174 @@ class DimplexControl:
|
|
|
109
136
|
async def get_hubs(self) -> list[Hub]:
|
|
110
137
|
"""Get all hubs for the user."""
|
|
111
138
|
data = await self._request("GET", "/Hubs/GetUserHubs")
|
|
112
|
-
|
|
113
|
-
return [Hub(**h) for h in data]
|
|
139
|
+
return [Hub.model_validate(h) for h in data]
|
|
114
140
|
|
|
115
141
|
async def get_hub_zones(self, hub_id: str) -> list[Zone]:
|
|
116
142
|
"""Get zones and appliances for a hub."""
|
|
117
143
|
data = await self._request("GET", "/Zones/GetZonesAndAppliancesForHubId", params={"HubId": hub_id})
|
|
118
|
-
return [Zone(
|
|
144
|
+
return [Zone.model_validate(z) for z in data]
|
|
119
145
|
|
|
120
146
|
async def get_zone(self, hub_id: str, zone_id: str) -> Zone:
|
|
121
147
|
"""Get details for a specific zone."""
|
|
122
148
|
payload = {"HubId": hub_id, "ZoneId": zone_id}
|
|
123
149
|
data = await self._request("POST", "/Zones/GetZone", json=payload)
|
|
124
|
-
return Zone(
|
|
150
|
+
return Zone.model_validate(data)
|
|
125
151
|
|
|
126
152
|
async def get_appliance_overview(self, hub_id: str, appliance_ids: list[str]) -> list[ApplianceStatus]:
|
|
127
|
-
"""Get status overview for specific appliances.
|
|
153
|
+
"""Get status overview for specific appliances.
|
|
154
|
+
|
|
155
|
+
When appliances are offline the cloud may return an empty list with
|
|
156
|
+
HTTP 200. That is success — use :meth:`get_appliance_overview_map` if
|
|
157
|
+
you need a stable id → status mapping.
|
|
158
|
+
"""
|
|
128
159
|
payload = {"HubId": hub_id, "ApplianceIds": appliance_ids}
|
|
129
160
|
data = await self._request("POST", "/RemoteControl/GetApplianceOverview", json=payload)
|
|
130
|
-
|
|
161
|
+
if not data:
|
|
162
|
+
return []
|
|
163
|
+
return [ApplianceStatus.model_validate(item) for item in data]
|
|
164
|
+
|
|
165
|
+
async def get_appliance_overview_map(
|
|
166
|
+
self, hub_id: str, appliance_ids: list[str]
|
|
167
|
+
) -> dict[str, ApplianceStatus | None]:
|
|
168
|
+
"""Return a map of appliance id → status (``None`` when missing)."""
|
|
169
|
+
overview = await self.get_appliance_overview(hub_id, appliance_ids)
|
|
170
|
+
by_id = {status.ApplianceId: status for status in overview}
|
|
171
|
+
return {appliance_id: by_id.get(appliance_id) for appliance_id in appliance_ids}
|
|
131
172
|
|
|
132
173
|
async def get_user_context(self) -> UserContext:
|
|
133
174
|
"""Get user profile/context."""
|
|
134
175
|
data = await self._request("GET", "/Identity/GetUserContext")
|
|
135
|
-
return UserContext(
|
|
176
|
+
return UserContext.model_validate(data)
|
|
177
|
+
|
|
178
|
+
async def get_product_models(self) -> list[ProductModel]:
|
|
179
|
+
"""Return the cloud product catalogue (models + provisioning metadata).
|
|
180
|
+
|
|
181
|
+
The catalogue is largely static; callers may cache the result.
|
|
182
|
+
"""
|
|
183
|
+
data = await self._request("GET", "/Appliances/GetProductModels")
|
|
184
|
+
if not data:
|
|
185
|
+
return []
|
|
186
|
+
return [ProductModel.model_validate(item) for item in data]
|
|
136
187
|
|
|
137
188
|
async def get_appliance_features(self, hub_id: str, appliance_id: str) -> TimerModeSettings:
|
|
138
189
|
"""Get timer details (and mode) for an appliance."""
|
|
139
|
-
# In the logs, this endpoint returns current mode and timer profiles
|
|
140
190
|
payload = {
|
|
141
191
|
"HubId": hub_id,
|
|
142
192
|
"ApplianceId": appliance_id,
|
|
143
|
-
"TimerMode": 0, # Required field in request
|
|
193
|
+
"TimerMode": 0, # Required field in request; value ignored on read
|
|
144
194
|
}
|
|
145
195
|
data = await self._request("POST", "/RemoteControl/GetTimerModeDetailsForAppliance", json=payload)
|
|
146
|
-
return TimerModeSettings(
|
|
196
|
+
return TimerModeSettings.model_validate(data)
|
|
147
197
|
|
|
148
|
-
async def set_mode(self, hub_id: str, appliance_id: str, mode: int) -> None:
|
|
149
|
-
"""Set the operation mode.
|
|
198
|
+
async def set_mode(self, hub_id: str, appliance_id: str, mode: int | TimerMode) -> None:
|
|
199
|
+
"""Set the timer / operation mode.
|
|
150
200
|
|
|
151
|
-
|
|
152
|
-
0: Manual? (User Timer?)
|
|
153
|
-
1: Manual
|
|
154
|
-
2: Frost Protection
|
|
155
|
-
3: Off?
|
|
201
|
+
See :class:`~dimplex_controller.models.TimerMode` for known values.
|
|
156
202
|
"""
|
|
157
|
-
# We need to fetch current settings first to preserve other fields if API requires full object
|
|
158
203
|
current = await self.get_appliance_features(hub_id, appliance_id)
|
|
159
|
-
current.TimerMode = mode
|
|
160
|
-
|
|
161
|
-
payload = {"TimerModeSettings": current.dict()}
|
|
204
|
+
current.TimerMode = int(mode)
|
|
162
205
|
|
|
206
|
+
payload = {"TimerModeSettings": current.model_dump(mode="json")}
|
|
163
207
|
await self._request("POST", "/RemoteControl/SetTimerMode", json=payload)
|
|
164
208
|
|
|
165
209
|
async def set_target_temperature(self, hub_id: str, appliance_id: str, temp: float) -> None:
|
|
166
|
-
"""Set target temperature.
|
|
210
|
+
"""Set the target / comfort temperature for an appliance.
|
|
211
|
+
|
|
212
|
+
The Dimplex cloud stores setpoints on timer periods. This method:
|
|
213
|
+
|
|
214
|
+
1. Loads the current timer configuration.
|
|
215
|
+
2. Updates every period's temperature (preserving day/time windows).
|
|
216
|
+
3. If no periods exist (common for some Quantum configs), installs a
|
|
217
|
+
full-week 00:00–23:59 schedule at the requested temperature in
|
|
218
|
+
manual mode so the cloud has a concrete setpoint to apply.
|
|
167
219
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
For now, this is a placeholder/advanced TODO.
|
|
220
|
+
This matches the reverse-engineered mobile-app approach of rewriting
|
|
221
|
+
the active schedule rather than a dedicated "set temperature" RPC.
|
|
171
222
|
"""
|
|
172
|
-
|
|
173
|
-
|
|
223
|
+
current = await self.get_appliance_features(hub_id, appliance_id)
|
|
224
|
+
|
|
225
|
+
if current.TimerPeriods:
|
|
226
|
+
for period in current.TimerPeriods:
|
|
227
|
+
period.Temperature = float(temp)
|
|
228
|
+
else:
|
|
229
|
+
current.TimerMode = int(TimerMode.MANUAL)
|
|
230
|
+
current.TimerPeriods = [
|
|
231
|
+
TimerPeriod(
|
|
232
|
+
DayOfWeek=day,
|
|
233
|
+
StartTime="00:00:00",
|
|
234
|
+
EndTime="23:59:59",
|
|
235
|
+
Temperature=float(temp),
|
|
236
|
+
)
|
|
237
|
+
for day in range(7)
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
payload = {"TimerModeSettings": current.model_dump(mode="json")}
|
|
241
|
+
await self._request("POST", "/RemoteControl/SetTimerMode", json=payload)
|
|
174
242
|
|
|
175
243
|
async def set_appliance_mode(
|
|
176
244
|
self, hub_id: str, appliance_ids: list[str], mode_settings: ApplianceModeSettings
|
|
177
245
|
) -> None:
|
|
178
246
|
"""Set appliance mode (Boost, Away, etc.)."""
|
|
179
|
-
payload = {
|
|
247
|
+
payload = {
|
|
248
|
+
"Settings": mode_settings.model_dump(mode="json"),
|
|
249
|
+
"HubId": hub_id,
|
|
250
|
+
"ApplianceIds": appliance_ids,
|
|
251
|
+
}
|
|
180
252
|
await self._request("POST", "/RemoteControl/SetApplianceMode", json=payload)
|
|
181
253
|
|
|
254
|
+
async def set_boost(
|
|
255
|
+
self,
|
|
256
|
+
hub_id: str,
|
|
257
|
+
appliance_ids: list[str],
|
|
258
|
+
*,
|
|
259
|
+
temperature: float,
|
|
260
|
+
duration_minutes: int = DEFAULT_BOOST_MINUTES,
|
|
261
|
+
enable: bool = True,
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Enable or disable Boost for one or more appliances.
|
|
264
|
+
|
|
265
|
+
The mobile app uses ``ApplianceModes=16`` with ``Status=1`` (on) /
|
|
266
|
+
``Status=0`` (off). ``Time`` carries the boost duration in minutes.
|
|
267
|
+
"""
|
|
268
|
+
settings = ApplianceModeSettings(
|
|
269
|
+
ApplianceModes=int(ApplianceModeFlag.BOOST),
|
|
270
|
+
Status=1 if enable else 0,
|
|
271
|
+
Temperature=float(temperature),
|
|
272
|
+
Time=int(duration_minutes) if enable else 0,
|
|
273
|
+
)
|
|
274
|
+
await self.set_appliance_mode(hub_id, appliance_ids, settings)
|
|
275
|
+
|
|
276
|
+
async def clear_boost(self, hub_id: str, appliance_ids: list[str], *, temperature: float = 21.0) -> None:
|
|
277
|
+
"""Disable Boost for the given appliances."""
|
|
278
|
+
await self.set_boost(hub_id, appliance_ids, temperature=temperature, duration_minutes=0, enable=False)
|
|
279
|
+
|
|
280
|
+
async def set_away(
|
|
281
|
+
self,
|
|
282
|
+
hub_id: str,
|
|
283
|
+
appliance_ids: list[str],
|
|
284
|
+
*,
|
|
285
|
+
temperature: float,
|
|
286
|
+
enable: bool = True,
|
|
287
|
+
number_of_days: int = 0,
|
|
288
|
+
) -> None:
|
|
289
|
+
"""Enable or disable Away mode.
|
|
290
|
+
|
|
291
|
+
Uses ``ApplianceModes=32`` (best-effort; confirmed via status-frame
|
|
292
|
+
pairing with Away* fields). Prefer verifying on a live appliance
|
|
293
|
+
after firmware updates.
|
|
294
|
+
"""
|
|
295
|
+
settings = ApplianceModeSettings(
|
|
296
|
+
ApplianceModes=int(ApplianceModeFlag.AWAY),
|
|
297
|
+
Status=1 if enable else 0,
|
|
298
|
+
Temperature=float(temperature),
|
|
299
|
+
NumberOfDays=int(number_of_days) if enable else 0,
|
|
300
|
+
)
|
|
301
|
+
await self.set_appliance_mode(hub_id, appliance_ids, settings)
|
|
302
|
+
|
|
303
|
+
async def clear_away(self, hub_id: str, appliance_ids: list[str], *, temperature: float = 16.0) -> None:
|
|
304
|
+
"""Disable Away mode for the given appliances."""
|
|
305
|
+
await self.set_away(hub_id, appliance_ids, temperature=temperature, enable=False)
|
|
306
|
+
|
|
182
307
|
async def set_eco_start(self, hub_id: str, appliance_ids: list[str], enable: bool) -> None:
|
|
183
308
|
"""Enable/Disable EcoStart."""
|
|
184
309
|
payload = {"Enable": enable, "HubId": hub_id, "ApplianceIds": appliance_ids}
|
|
@@ -196,19 +321,24 @@ class DimplexControl:
|
|
|
196
321
|
interval: str = DEFAULT_TSI_INTERVAL,
|
|
197
322
|
start_date: str | None = None,
|
|
198
323
|
end_date: str | None = None,
|
|
199
|
-
include_previous_period: bool =
|
|
324
|
+
include_previous_period: bool = True,
|
|
200
325
|
days_back: int = DEFAULT_TSI_REPORT_DAYS,
|
|
201
326
|
) -> TsiEnergyReport:
|
|
202
327
|
"""Fetch the Time Series Insights energy report for a hub.
|
|
203
328
|
|
|
204
329
|
Returns a :class:`~dimplex_controller.models.TsiEnergyReport`. Each
|
|
205
330
|
per-appliance list is left as the raw payload — use
|
|
206
|
-
:func:`dimplex_controller.telemetry.parse_telemetry_points`
|
|
207
|
-
|
|
331
|
+
:func:`dimplex_controller.telemetry.parse_telemetry_points` and
|
|
332
|
+
:func:`dimplex_controller.telemetry.summarise_energy` to normalise
|
|
333
|
+
and aggregate.
|
|
208
334
|
|
|
209
335
|
When the hub has no metered appliances (e.g. non-QRAD heaters, or a
|
|
210
336
|
quiet summer hub) the per-appliance lists come back empty; that is
|
|
211
337
|
treated as success, not an error.
|
|
338
|
+
|
|
339
|
+
Note: with ``include_previous_period=True`` (the default) the cloud
|
|
340
|
+
frequently returns the **full available daily history**, not only the
|
|
341
|
+
``days_back`` window. Filter client-side for daily/lifetime totals.
|
|
212
342
|
"""
|
|
213
343
|
if start_date is None:
|
|
214
344
|
start_date = _iso_utc_days_ago(days_back)
|
|
@@ -1,7 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import json
|
|
2
4
|
from datetime import datetime, time
|
|
5
|
+
from enum import IntEnum, IntFlag
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TimerMode(IntEnum):
|
|
11
|
+
"""Observed timer / operation mode values for ``TimerModeSettings.TimerMode``.
|
|
12
|
+
|
|
13
|
+
Exact product names vary by appliance firmware; these are the values used
|
|
14
|
+
by the Dimplex Control mobile client and reverse-engineered traffic.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
USER_TIMER = 0
|
|
18
|
+
MANUAL = 1
|
|
19
|
+
FROST_PROTECTION = 2
|
|
20
|
+
OFF = 3
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ApplianceModeFlag(IntFlag):
|
|
24
|
+
"""Bit flags for ``ApplianceStatus.ApplianceModes`` / mode write payloads.
|
|
25
|
+
|
|
26
|
+
Only bits confirmed in traffic or the official app docs are named.
|
|
27
|
+
``BOOST`` (16) is used by the mobile app for boost mode.
|
|
28
|
+
``AWAY`` (32) is inferred from status frame pairing with Away* fields —
|
|
29
|
+
treat as best-effort until more captures confirm it for every model.
|
|
30
|
+
"""
|
|
3
31
|
|
|
4
|
-
|
|
32
|
+
NONE = 0
|
|
33
|
+
BOOST = 16
|
|
34
|
+
AWAY = 32
|
|
5
35
|
|
|
6
36
|
|
|
7
37
|
class AutomaticProvisioning(BaseModel):
|
|
@@ -9,8 +39,16 @@ class AutomaticProvisioning(BaseModel):
|
|
|
9
39
|
|
|
10
40
|
The cloud stores this as a JSON string; :class:`Appliance` decodes it
|
|
11
41
|
automatically so callers can read the heater's electrical characteristics.
|
|
42
|
+
|
|
43
|
+
Observed units (from product catalogue / live Quantum payloads):
|
|
44
|
+
|
|
45
|
+
* power ratings — kW
|
|
46
|
+
* charge capacity — kWh
|
|
47
|
+
* charge element resistance — ohms
|
|
12
48
|
"""
|
|
13
49
|
|
|
50
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
51
|
+
|
|
14
52
|
bottom_element_power_rating: float | None = Field(None, alias="bottomElementPowerRating")
|
|
15
53
|
top_element_power_rating: float | None = Field(None, alias="topElementPowerRating")
|
|
16
54
|
rated_power: float | None = Field(None, alias="ratedPower")
|
|
@@ -19,6 +57,38 @@ class AutomaticProvisioning(BaseModel):
|
|
|
19
57
|
power_offset: float | None = Field(None, alias="powerOffset")
|
|
20
58
|
|
|
21
59
|
|
|
60
|
+
class ProductModel(BaseModel):
|
|
61
|
+
"""A row from ``GET /Appliances/GetProductModels``."""
|
|
62
|
+
|
|
63
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
64
|
+
|
|
65
|
+
ProductModelId: str | None = None
|
|
66
|
+
ProductTypeId: str | None = None
|
|
67
|
+
ProductModelName: str | None = None
|
|
68
|
+
ProductTypeName: str | None = None
|
|
69
|
+
ProductModelExtensions: dict[str, str] | None = None
|
|
70
|
+
|
|
71
|
+
@field_validator("ProductModelExtensions", mode="before")
|
|
72
|
+
@classmethod
|
|
73
|
+
def _coerce_extensions(cls, value):
|
|
74
|
+
if value is None:
|
|
75
|
+
return None
|
|
76
|
+
if isinstance(value, dict):
|
|
77
|
+
return {str(k): v if isinstance(v, str) else json.dumps(v) for k, v in value.items()}
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def automatic_provisioning(self) -> AutomaticProvisioning | None:
|
|
82
|
+
"""Return decoded AUTOMATIC_PROVISIONING, if present."""
|
|
83
|
+
raw = (self.ProductModelExtensions or {}).get("AUTOMATIC_PROVISIONING")
|
|
84
|
+
if not raw:
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
return AutomaticProvisioning.model_validate_json(raw)
|
|
88
|
+
except (json.JSONDecodeError, ValueError):
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
22
92
|
class Appliance(BaseModel):
|
|
23
93
|
ApplianceId: str
|
|
24
94
|
ApplianceType: str
|
|
@@ -137,7 +207,7 @@ class ApplianceStatus(BaseModel):
|
|
|
137
207
|
StatusTwo: int | None = None
|
|
138
208
|
ApplianceModes: int | None = None
|
|
139
209
|
RoomTemperature: float | None = None
|
|
140
|
-
ActiveSetPointTemperature:
|
|
210
|
+
ActiveSetPointTemperature: float | None = None
|
|
141
211
|
NormalTemperature: float | None = None
|
|
142
212
|
AwayDateTime: str | None = None
|
|
143
213
|
AwayTemperature: float | None = None
|
|
@@ -154,6 +224,27 @@ class ApplianceStatus(BaseModel):
|
|
|
154
224
|
ErrorCode: str | None = None
|
|
155
225
|
WarningCode: str | None = None
|
|
156
226
|
|
|
227
|
+
@property
|
|
228
|
+
def mode_flags(self) -> ApplianceModeFlag:
|
|
229
|
+
"""Return ``ApplianceModes`` as a typed flag set."""
|
|
230
|
+
if self.ApplianceModes is None:
|
|
231
|
+
return ApplianceModeFlag.NONE
|
|
232
|
+
return ApplianceModeFlag(self.ApplianceModes)
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def is_boost_active(self) -> bool:
|
|
236
|
+
"""True when boost duration is set or the boost mode bit is present."""
|
|
237
|
+
if self.BoostDuration is not None and self.BoostDuration > 0:
|
|
238
|
+
return True
|
|
239
|
+
return bool(self.mode_flags & ApplianceModeFlag.BOOST)
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def is_away_active(self) -> bool:
|
|
243
|
+
"""True when away fields indicate an active away session."""
|
|
244
|
+
if self.AwayDateTime and self.AwayDateTime not in ("", "0001-01-01T00:00:00"):
|
|
245
|
+
return True
|
|
246
|
+
return bool(self.mode_flags & ApplianceModeFlag.AWAY)
|
|
247
|
+
|
|
157
248
|
|
|
158
249
|
class ApplianceModeSettings(BaseModel):
|
|
159
250
|
"""Settings used to control appliance modes like Boost or Away."""
|
|
File without changes
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Telemetry parsing for the Dimplex Reports API.
|
|
2
|
+
|
|
3
|
+
The response from ``POST /Reports/GetTsiEnergyReportDataForHub`` contains
|
|
4
|
+
``ApplianceTelemetryData``: a dict keyed by appliance id, with one list of
|
|
5
|
+
``telemetry points`` per appliance. The shape of each point is not documented
|
|
6
|
+
and varies between firmware versions, so we normalise whatever the cloud
|
|
7
|
+
sends into ``(timestamp, value)`` tuples.
|
|
8
|
+
|
|
9
|
+
Real-world payloads have been observed using ``TS`` (Unix-epoch timestamp)
|
|
10
|
+
with either ``T1`` or ``ST`` as the energy value key.
|
|
11
|
+
|
|
12
|
+
Important cloud semantics (live-verified):
|
|
13
|
+
|
|
14
|
+
* With ``IncludePreviousPeriod=true`` the cloud often returns **the full
|
|
15
|
+
available daily history** (from first telem / install), not just the
|
|
16
|
+
``StartDate``→``EndDate`` window.
|
|
17
|
+
* With ``IncludePreviousPeriod=false`` and idle heaters, lists may be empty.
|
|
18
|
+
* Points are typically **one kWh sample per calendar day**, not a continuous
|
|
19
|
+
cumulative counter.
|
|
20
|
+
|
|
21
|
+
Use :func:`summarise_energy` for the two product totals:
|
|
22
|
+
|
|
23
|
+
* ``daily`` — kWh for the local calendar day (from local midnight)
|
|
24
|
+
* ``lifetime`` — sum of all parsed points (earliest → latest)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from collections.abc import Iterable, Sequence
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from datetime import date, datetime, time, timezone, tzinfo
|
|
32
|
+
from typing import Any, Literal
|
|
33
|
+
|
|
34
|
+
# Keys the cloud has been observed using, in priority order. The first match
|
|
35
|
+
# wins. Case-insensitive lookup is done by lowercasing the dict before
|
|
36
|
+
# scanning, so callers do not have to worry about casing.
|
|
37
|
+
_DEFAULT_TIMESTAMP_KEYS = (
|
|
38
|
+
"timestamp",
|
|
39
|
+
"time",
|
|
40
|
+
"ts",
|
|
41
|
+
"t",
|
|
42
|
+
"datetime",
|
|
43
|
+
"date",
|
|
44
|
+
"from",
|
|
45
|
+
"start",
|
|
46
|
+
)
|
|
47
|
+
_DEFAULT_VALUE_KEYS = (
|
|
48
|
+
"t1",
|
|
49
|
+
"st", # Observed on QRAD050F / QRAD075F energy reports (see dimplex-controller-hass #27).
|
|
50
|
+
"value",
|
|
51
|
+
"kwh",
|
|
52
|
+
"energy",
|
|
53
|
+
"consumption",
|
|
54
|
+
"energykwh",
|
|
55
|
+
"amount",
|
|
56
|
+
"v",
|
|
57
|
+
# Fallback for appliances that only report the secondary register.
|
|
58
|
+
"t2",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Secondary energy register observed for some Quantum appliances. Points may
|
|
62
|
+
# contain both ``T1`` and ``T2`` in the same payload.
|
|
63
|
+
VALUE_KEY_T2 = (
|
|
64
|
+
"t2",
|
|
65
|
+
"value2",
|
|
66
|
+
"kwh2",
|
|
67
|
+
"energy2",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
EnergyMode = Literal["daily", "lifetime", "window"]
|
|
71
|
+
TelemetryPoint = tuple[datetime | None, float]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class EnergySummary:
|
|
76
|
+
"""Aggregated energy for a set of telemetry points.
|
|
77
|
+
|
|
78
|
+
Attributes:
|
|
79
|
+
total_kwh: Sum of point values in kWh.
|
|
80
|
+
point_count: Number of points included in the total.
|
|
81
|
+
start: Earliest timestamp among included points (or day start for daily).
|
|
82
|
+
end: Latest timestamp among included points (or None).
|
|
83
|
+
mode: Aggregation mode used to produce this summary.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
total_kwh: float
|
|
87
|
+
point_count: int
|
|
88
|
+
start: datetime | None
|
|
89
|
+
end: datetime | None
|
|
90
|
+
mode: EnergyMode
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _coerce_timestamp(raw: Any) -> datetime | None:
|
|
94
|
+
"""Best-effort conversion of ``raw`` into a ``datetime``.
|
|
95
|
+
|
|
96
|
+
Accepts ``datetime`` instances, ISO-8601 strings, and Unix epoch numbers
|
|
97
|
+
(seconds or milliseconds). Returns ``None`` if the value cannot be parsed.
|
|
98
|
+
"""
|
|
99
|
+
if raw is None:
|
|
100
|
+
return None
|
|
101
|
+
if isinstance(raw, datetime):
|
|
102
|
+
return raw
|
|
103
|
+
if isinstance(raw, int | float):
|
|
104
|
+
ts = float(raw)
|
|
105
|
+
if ts > 1e12:
|
|
106
|
+
ts = ts / 1000.0
|
|
107
|
+
try:
|
|
108
|
+
return datetime.fromtimestamp(ts, tz=timezone.utc)
|
|
109
|
+
except (OverflowError, OSError, ValueError):
|
|
110
|
+
return None
|
|
111
|
+
if isinstance(raw, str):
|
|
112
|
+
text = raw.strip()
|
|
113
|
+
if not text:
|
|
114
|
+
return None
|
|
115
|
+
try:
|
|
116
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
117
|
+
except ValueError:
|
|
118
|
+
return None
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _coerce_value(raw: Any) -> float | None:
|
|
123
|
+
"""Best-effort conversion of ``raw`` into a ``float``."""
|
|
124
|
+
if raw is None:
|
|
125
|
+
return None
|
|
126
|
+
if isinstance(raw, bool):
|
|
127
|
+
return None
|
|
128
|
+
if isinstance(raw, int | float):
|
|
129
|
+
return float(raw)
|
|
130
|
+
if isinstance(raw, str):
|
|
131
|
+
try:
|
|
132
|
+
return float(raw.strip())
|
|
133
|
+
except ValueError:
|
|
134
|
+
return None
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _iter_items(point: Any) -> Iterable[tuple[str, Any]] | None:
|
|
139
|
+
"""Yield ``(key, value)`` pairs from a dict-like ``point``.
|
|
140
|
+
|
|
141
|
+
Returns ``None`` for non-dict points so the caller can fall through to the
|
|
142
|
+
list / scalar branches.
|
|
143
|
+
"""
|
|
144
|
+
if not isinstance(point, dict):
|
|
145
|
+
return None
|
|
146
|
+
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))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def parse_telemetry_points(
|
|
151
|
+
points: Any,
|
|
152
|
+
*,
|
|
153
|
+
value_keys: tuple[str, ...] | None = None,
|
|
154
|
+
) -> list[TelemetryPoint]:
|
|
155
|
+
"""Normalise a list of telemetry points into ``(timestamp, value)`` pairs.
|
|
156
|
+
|
|
157
|
+
Each entry in ``points`` may be:
|
|
158
|
+
|
|
159
|
+
* a ``dict`` with one of the recognised timestamp/value keys
|
|
160
|
+
* a 2-element ``[timestamp, value]`` list or tuple
|
|
161
|
+
* a bare scalar (treated as a cumulative value at an unknown timestamp)
|
|
162
|
+
|
|
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.
|
|
166
|
+
|
|
167
|
+
Unparseable entries are silently skipped. The order of the input list is
|
|
168
|
+
preserved.
|
|
169
|
+
"""
|
|
170
|
+
if not isinstance(points, list):
|
|
171
|
+
return []
|
|
172
|
+
|
|
173
|
+
value_key_order = value_keys if value_keys is not None else _DEFAULT_VALUE_KEYS
|
|
174
|
+
timestamp_keys = _DEFAULT_TIMESTAMP_KEYS
|
|
175
|
+
|
|
176
|
+
out: list[TelemetryPoint] = []
|
|
177
|
+
for point in points:
|
|
178
|
+
ts: datetime | None = None
|
|
179
|
+
value: float | None = None
|
|
180
|
+
|
|
181
|
+
items = _iter_items(point)
|
|
182
|
+
if items is not None:
|
|
183
|
+
for key, raw in items:
|
|
184
|
+
if key in timestamp_keys and ts is None:
|
|
185
|
+
ts = _coerce_timestamp(raw)
|
|
186
|
+
elif key in value_key_order and value is None:
|
|
187
|
+
value = _coerce_value(raw)
|
|
188
|
+
elif isinstance(point, list | tuple) and len(point) == 2:
|
|
189
|
+
ts = _coerce_timestamp(point[0])
|
|
190
|
+
value = _coerce_value(point[1])
|
|
191
|
+
else:
|
|
192
|
+
value = _coerce_value(point)
|
|
193
|
+
|
|
194
|
+
if value is None:
|
|
195
|
+
continue
|
|
196
|
+
out.append((ts, value))
|
|
197
|
+
return out
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _ensure_aware(ts: datetime, default_tz: tzinfo) -> datetime:
|
|
201
|
+
"""Return ``ts`` with a timezone; naive datetimes are assumed ``default_tz``."""
|
|
202
|
+
if ts.tzinfo is None:
|
|
203
|
+
return ts.replace(tzinfo=default_tz)
|
|
204
|
+
return ts
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def local_midnight(now: datetime, tz: tzinfo) -> datetime:
|
|
208
|
+
"""Return the start of the local calendar day containing ``now``."""
|
|
209
|
+
local = _ensure_aware(now, tz).astimezone(tz)
|
|
210
|
+
return datetime.combine(local.date(), time.min, tzinfo=tz)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def filter_telemetry_points(
|
|
214
|
+
points: Sequence[TelemetryPoint],
|
|
215
|
+
*,
|
|
216
|
+
start: datetime | None = None,
|
|
217
|
+
end: datetime | None = None,
|
|
218
|
+
on_date: date | None = None,
|
|
219
|
+
tz: tzinfo = timezone.utc,
|
|
220
|
+
) -> list[TelemetryPoint]:
|
|
221
|
+
"""Filter parsed points by absolute window and/or local calendar date.
|
|
222
|
+
|
|
223
|
+
* ``start`` / ``end`` are inclusive bounds (compared in UTC).
|
|
224
|
+
* ``on_date`` keeps points whose local date (in ``tz``) equals that day.
|
|
225
|
+
Points with a missing timestamp are dropped when any filter is active.
|
|
226
|
+
"""
|
|
227
|
+
if start is None and end is None and on_date is None:
|
|
228
|
+
return list(points)
|
|
229
|
+
|
|
230
|
+
start_utc = _ensure_aware(start, tz).astimezone(timezone.utc) if start is not None else None
|
|
231
|
+
end_utc = _ensure_aware(end, tz).astimezone(timezone.utc) if end is not None else None
|
|
232
|
+
|
|
233
|
+
out: list[TelemetryPoint] = []
|
|
234
|
+
for ts, value in points:
|
|
235
|
+
if ts is None:
|
|
236
|
+
continue
|
|
237
|
+
aware = _ensure_aware(ts, timezone.utc)
|
|
238
|
+
if start_utc is not None and aware < start_utc:
|
|
239
|
+
continue
|
|
240
|
+
if end_utc is not None and aware > end_utc:
|
|
241
|
+
continue
|
|
242
|
+
if on_date is not None and aware.astimezone(tz).date() != on_date:
|
|
243
|
+
continue
|
|
244
|
+
out.append((aware, value))
|
|
245
|
+
return out
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def summarise_energy(
|
|
249
|
+
points: Sequence[TelemetryPoint] | Any,
|
|
250
|
+
*,
|
|
251
|
+
mode: EnergyMode = "lifetime",
|
|
252
|
+
now: datetime | None = None,
|
|
253
|
+
tz: tzinfo = timezone.utc,
|
|
254
|
+
start: datetime | None = None,
|
|
255
|
+
end: datetime | None = None,
|
|
256
|
+
value_keys: tuple[str, ...] | None = None,
|
|
257
|
+
) -> EnergySummary:
|
|
258
|
+
"""Aggregate telemetry into a daily, lifetime, or custom-window total.
|
|
259
|
+
|
|
260
|
+
``points`` may be raw cloud payloads or already-parsed
|
|
261
|
+
:data:`TelemetryPoint` tuples. When raw, they are passed through
|
|
262
|
+
:func:`parse_telemetry_points` first.
|
|
263
|
+
"""
|
|
264
|
+
parsed: list[TelemetryPoint]
|
|
265
|
+
if (
|
|
266
|
+
isinstance(points, Sequence)
|
|
267
|
+
and not isinstance(points, str | bytes)
|
|
268
|
+
and points
|
|
269
|
+
and isinstance(points[0], tuple)
|
|
270
|
+
and len(points[0]) == 2
|
|
271
|
+
):
|
|
272
|
+
parsed = list(points) # type: ignore[arg-type]
|
|
273
|
+
else:
|
|
274
|
+
parsed = parse_telemetry_points(points, value_keys=value_keys)
|
|
275
|
+
|
|
276
|
+
ref_now = now if now is not None else datetime.now(timezone.utc)
|
|
277
|
+
ref_now = _ensure_aware(ref_now, tz)
|
|
278
|
+
|
|
279
|
+
if mode == "daily":
|
|
280
|
+
day = ref_now.astimezone(tz).date()
|
|
281
|
+
selected = filter_telemetry_points(parsed, on_date=day, tz=tz)
|
|
282
|
+
day_start = local_midnight(ref_now, tz)
|
|
283
|
+
timestamps = [ts for ts, _ in selected if ts is not None]
|
|
284
|
+
total = sum(v for _, v in selected)
|
|
285
|
+
return EnergySummary(
|
|
286
|
+
total_kwh=round(total, 3),
|
|
287
|
+
point_count=len(selected),
|
|
288
|
+
start=day_start,
|
|
289
|
+
end=max(timestamps) if timestamps else None,
|
|
290
|
+
mode="daily",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# lifetime — include every parseable point; window — optional start/end filter
|
|
294
|
+
selected = filter_telemetry_points(parsed, start=start, end=end, tz=tz) if mode == "window" else list(parsed)
|
|
295
|
+
|
|
296
|
+
timestamps = [ts for ts, _ in selected if ts is not None]
|
|
297
|
+
total = sum(v for _, v in selected)
|
|
298
|
+
return EnergySummary(
|
|
299
|
+
total_kwh=round(total, 3),
|
|
300
|
+
point_count=len(selected),
|
|
301
|
+
start=min(timestamps) if timestamps else None,
|
|
302
|
+
end=max(timestamps) if timestamps else None,
|
|
303
|
+
mode=mode,
|
|
304
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "dimplex-controller"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.0"
|
|
4
4
|
description = "Python client for Dimplex heating controllers (GDHV IoT)"
|
|
5
5
|
authors = ["Kieran Roper"]
|
|
6
6
|
license = "MIT"
|
|
@@ -28,7 +28,7 @@ python = "^3.10"
|
|
|
28
28
|
aiohttp = "^3.9.0"
|
|
29
29
|
pydantic = "^2.0.0"
|
|
30
30
|
beautifulsoup4 = "^4.14.3"
|
|
31
|
-
|
|
31
|
+
|
|
32
32
|
[tool.poetry.group.dev.dependencies]
|
|
33
33
|
pytest = "^8.0.0"
|
|
34
34
|
pytest-asyncio = "^0.23.0"
|
|
@@ -37,6 +37,8 @@ aresponses = "^3.0.0"
|
|
|
37
37
|
ruff = "^0.3.0"
|
|
38
38
|
pre-commit = "^3.6.0"
|
|
39
39
|
twine = "^6.2.0"
|
|
40
|
+
python-dotenv = "^1.2.1"
|
|
41
|
+
mypy = "^1.0.0"
|
|
40
42
|
|
|
41
43
|
[tool.ruff]
|
|
42
44
|
line-length = 120
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
"""Telemetry parsing for the Dimplex Reports API.
|
|
2
|
-
|
|
3
|
-
The response from ``POST /Reports/GetTsiEnergyReportDataForHub`` contains
|
|
4
|
-
``ApplianceTelemetryData``: a dict keyed by appliance id, with one list of
|
|
5
|
-
``telemetry points`` per appliance. The shape of each point is not documented
|
|
6
|
-
and varies between firmware versions, so we normalise whatever the cloud
|
|
7
|
-
sends into ``(timestamp, value)`` tuples.
|
|
8
|
-
|
|
9
|
-
Real-world payloads have been observed using ``TS`` (Unix-epoch timestamp)
|
|
10
|
-
with either ``T1`` or ``ST`` as the energy value key.
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
from collections.abc import Iterable
|
|
16
|
-
from datetime import datetime, timezone
|
|
17
|
-
from typing import Any
|
|
18
|
-
|
|
19
|
-
# Keys the cloud has been observed using, in priority order. The first match
|
|
20
|
-
# wins. Case-insensitive lookup is done by lowercasing the dict before
|
|
21
|
-
# scanning, so callers do not have to worry about casing.
|
|
22
|
-
_DEFAULT_TIMESTAMP_KEYS = (
|
|
23
|
-
"timestamp",
|
|
24
|
-
"time",
|
|
25
|
-
"ts",
|
|
26
|
-
"t",
|
|
27
|
-
"datetime",
|
|
28
|
-
"date",
|
|
29
|
-
"from",
|
|
30
|
-
"start",
|
|
31
|
-
)
|
|
32
|
-
_DEFAULT_VALUE_KEYS = (
|
|
33
|
-
"t1",
|
|
34
|
-
"st", # Observed on QRAD050F / QRAD075F energy reports (see dimplex-controller-hass #27).
|
|
35
|
-
"value",
|
|
36
|
-
"kwh",
|
|
37
|
-
"energy",
|
|
38
|
-
"consumption",
|
|
39
|
-
"energykwh",
|
|
40
|
-
"amount",
|
|
41
|
-
"v",
|
|
42
|
-
# Fallback for appliances that only report the secondary register.
|
|
43
|
-
"t2",
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
# Secondary energy register observed for some Quantum appliances. Points may
|
|
47
|
-
# contain both ``T1`` and ``T2`` in the same payload.
|
|
48
|
-
VALUE_KEY_T2 = (
|
|
49
|
-
"t2",
|
|
50
|
-
"value2",
|
|
51
|
-
"kwh2",
|
|
52
|
-
"energy2",
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def _coerce_timestamp(raw: Any) -> datetime | None:
|
|
57
|
-
"""Best-effort conversion of ``raw`` into a ``datetime``.
|
|
58
|
-
|
|
59
|
-
Accepts ``datetime`` instances, ISO-8601 strings, and Unix epoch numbers
|
|
60
|
-
(seconds or milliseconds). Returns ``None`` if the value cannot be parsed.
|
|
61
|
-
"""
|
|
62
|
-
if raw is None:
|
|
63
|
-
return None
|
|
64
|
-
if isinstance(raw, datetime):
|
|
65
|
-
return raw
|
|
66
|
-
if isinstance(raw, int | float):
|
|
67
|
-
ts = float(raw)
|
|
68
|
-
if ts > 1e12:
|
|
69
|
-
ts = ts / 1000.0
|
|
70
|
-
try:
|
|
71
|
-
return datetime.fromtimestamp(ts, tz=timezone.utc)
|
|
72
|
-
except (OverflowError, OSError, ValueError):
|
|
73
|
-
return None
|
|
74
|
-
if isinstance(raw, str):
|
|
75
|
-
text = raw.strip()
|
|
76
|
-
if not text:
|
|
77
|
-
return None
|
|
78
|
-
try:
|
|
79
|
-
return datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
80
|
-
except ValueError:
|
|
81
|
-
return None
|
|
82
|
-
return None
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def _coerce_value(raw: Any) -> float | None:
|
|
86
|
-
"""Best-effort conversion of ``raw`` into a ``float``."""
|
|
87
|
-
if raw is None:
|
|
88
|
-
return None
|
|
89
|
-
if isinstance(raw, bool):
|
|
90
|
-
return None
|
|
91
|
-
if isinstance(raw, int | float):
|
|
92
|
-
return float(raw)
|
|
93
|
-
if isinstance(raw, str):
|
|
94
|
-
try:
|
|
95
|
-
return float(raw.strip())
|
|
96
|
-
except ValueError:
|
|
97
|
-
return None
|
|
98
|
-
return None
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def _iter_items(point: Any) -> Iterable[tuple[str, Any]] | None:
|
|
102
|
-
"""Yield ``(key, value)`` pairs from a dict-like ``point``.
|
|
103
|
-
|
|
104
|
-
Returns ``None`` for non-dict points so the caller can fall through to the
|
|
105
|
-
list / scalar branches.
|
|
106
|
-
"""
|
|
107
|
-
if not isinstance(point, dict):
|
|
108
|
-
return None
|
|
109
|
-
lower = {str(k).lower(): v for k, v in point.items()}
|
|
110
|
-
return ((k, lower.get(k)) for k in (*_DEFAULT_TIMESTAMP_KEYS, *_DEFAULT_VALUE_KEYS, *VALUE_KEY_T2))
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def parse_telemetry_points(
|
|
114
|
-
points: Any,
|
|
115
|
-
*,
|
|
116
|
-
value_keys: tuple[str, ...] | None = None,
|
|
117
|
-
) -> list[tuple[datetime | None, float]]:
|
|
118
|
-
"""Normalise a list of telemetry points into ``(timestamp, value)`` pairs.
|
|
119
|
-
|
|
120
|
-
Each entry in ``points`` may be:
|
|
121
|
-
|
|
122
|
-
* a ``dict`` with one of the recognised timestamp/value keys
|
|
123
|
-
* a 2-element ``[timestamp, value]`` list or tuple
|
|
124
|
-
* a bare scalar (treated as a cumulative value at an unknown timestamp)
|
|
125
|
-
|
|
126
|
-
``value_keys`` overrides the value-key priority list. Use
|
|
127
|
-
:data:`VALUE_KEY_T2` to extract the secondary energy register when the
|
|
128
|
-
cloud returns both ``T1`` and ``T2`` in the same payload.
|
|
129
|
-
|
|
130
|
-
Unparseable entries are silently skipped. The order of the input list is
|
|
131
|
-
preserved.
|
|
132
|
-
"""
|
|
133
|
-
if not isinstance(points, list):
|
|
134
|
-
return []
|
|
135
|
-
|
|
136
|
-
value_key_order = value_keys if value_keys is not None else _DEFAULT_VALUE_KEYS
|
|
137
|
-
timestamp_keys = _DEFAULT_TIMESTAMP_KEYS
|
|
138
|
-
|
|
139
|
-
out: list[tuple[datetime | None, float]] = []
|
|
140
|
-
for point in points:
|
|
141
|
-
ts: datetime | None = None
|
|
142
|
-
value: float | None = None
|
|
143
|
-
|
|
144
|
-
items = _iter_items(point)
|
|
145
|
-
if items is not None:
|
|
146
|
-
for key, raw in items:
|
|
147
|
-
if key in timestamp_keys and ts is None:
|
|
148
|
-
ts = _coerce_timestamp(raw)
|
|
149
|
-
elif key in value_key_order and value is None:
|
|
150
|
-
value = _coerce_value(raw)
|
|
151
|
-
elif isinstance(point, list | tuple) and len(point) == 2:
|
|
152
|
-
ts = _coerce_timestamp(point[0])
|
|
153
|
-
value = _coerce_value(point[1])
|
|
154
|
-
else:
|
|
155
|
-
value = _coerce_value(point)
|
|
156
|
-
|
|
157
|
-
if value is None:
|
|
158
|
-
continue
|
|
159
|
-
out.append((ts, value))
|
|
160
|
-
return out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|