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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dimplex-controller
3
- Version: 0.6.1
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 (Mode 16, Status 1 = On)
183
- boost_settings = ApplianceModeSettings(ApplianceModes=16, Status=1, Temperature=25.0)
184
- await client.set_appliance_mode(hub_id, [appliance_id], boost_settings)
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.telemetry.items():
194
+ for appliance_id, telemetry in report.ApplianceTelemetryData.items():
194
195
  points = parse_telemetry_points(telemetry)
195
- for timestamp, value in points:
196
- print(f"{appliance_id}: {value} kWh at {timestamp}")
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
- The `parse_telemetry_points` helper normalises arbitrary API response shapes (varying firmware formats) into `(timestamp, value)` tuples.
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 (Mode 16, Status 1 = On)
154
- boost_settings = ApplianceModeSettings(ApplianceModes=16, Status=1, Temperature=25.0)
155
- await client.set_appliance_mode(hub_id, [appliance_id], boost_settings)
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.telemetry.items():
166
+ for appliance_id, telemetry in report.ApplianceTelemetryData.items():
165
167
  points = parse_telemetry_points(telemetry)
166
- for timestamp, value in points:
167
- print(f"{appliance_id}: {value} kWh at {timestamp}")
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
- The `parse_telemetry_points` helper normalises arbitrary API response shapes (varying firmware formats) into `(timestamp, value)` tuples.
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 VALUE_KEY_T2, parse_telemetry_points
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__(self, session: aiohttp.ClientSession, token_data: dict | None = None):
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._access_token: str | None = token_data.get("access_token") if token_data else None
30
- self._refresh_token: str | None = token_data.get("refresh_token") if token_data else None
31
- self._expires_at: float = token_data.get("expires_at", 0) if token_data else 0
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
- return self._access_token # type: ignore[return-value]
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
- return self._access_token # type: ignore[return-value]
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
- self._refresh_token = data.get("refresh_token")
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; 30 days is a reasonable default that
34
- # keeps the response small while still giving the caller a meaningful window.
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
- token_data = {}
57
- if refresh_token:
58
- token_data["refresh_token"] = refresh_token
59
- if access_token:
60
- token_data["access_token"] = access_token
61
- if expires_at:
62
- token_data["expires_at"] = expires_at # type: ignore[assignment]
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
- async def _request(self, method: str, endpoint: str, **kwargs) -> dict:
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
- # Log analysis shows list of objects
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(**z) for z in data]
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(**data)
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
- return [ApplianceStatus(**item) for item in data]
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(**data)
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, value doesn't seem to matter for fetching?
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(**data)
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
- Modes (inferred):
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
- WARNING: The logs show setting temperature involves updating the 'TimerPeriods' for the current mode.
169
- This client might need to be smarter about which period to update (current active one).
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
- _LOGGER.warning("set_target_temperature not fully implemented - requires complex schedule manipulation")
173
- pass
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 = {"Settings": mode_settings.dict(), "HubId": hub_id, "ApplianceIds": appliance_ids}
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 = False,
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` to
207
- normalise the points into ``(timestamp, value)`` tuples.
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
- from pydantic import BaseModel, Field, field_validator
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: int | None = None
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.6.1"
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
- python-dotenv = "^1.2.1"
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