dimplex-controller 0.6.1__tar.gz → 0.7.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.7.0
4
4
  Summary: Python client for Dimplex heating controllers (GDHV IoT)
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -179,24 +179,27 @@ await client.set_eco_start(hub_id, [appliance_id], True)
179
179
  # Enable Open Window Detection
180
180
  await client.set_open_window_detection(hub_id, [appliance_id], True)
181
181
 
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)
182
+ # Activate Boost
183
+ await client.set_boost(hub_id, [appliance_id], temperature=25.0, duration_minutes=60)
184
+
185
+ # Set target temperature (rewrites timer period setpoints)
186
+ await client.set_target_temperature(hub_id, appliance_id, 21.5)
185
187
  ```
186
188
 
187
189
  ### Energy reports
188
190
 
189
191
  ```python
190
- from dimplex_controller import parse_telemetry_points
192
+ from dimplex_controller import parse_telemetry_points, summarise_energy
191
193
 
192
194
  report = await client.get_tsi_energy_report(hub_id)
193
- for appliance_id, telemetry in report.telemetry.items():
195
+ for appliance_id, telemetry in report.ApplianceTelemetryData.items():
194
196
  points = parse_telemetry_points(telemetry)
195
- for timestamp, value in points:
196
- print(f"{appliance_id}: {value} kWh at {timestamp}")
197
+ daily = summarise_energy(points, mode="daily")
198
+ lifetime = summarise_energy(points, mode="lifetime")
199
+ print(f"{appliance_id}: today={daily.total_kwh} kWh, lifetime={lifetime.total_kwh} kWh")
197
200
  ```
198
201
 
199
- The `parse_telemetry_points` helper normalises arbitrary API response shapes (varying firmware formats) into `(timestamp, value)` tuples.
202
+ `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
203
 
201
204
  ## Configuration
202
205
 
@@ -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,47 @@
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
+ TimerMode,
14
+ TimerModeSettings,
15
+ TimerPeriod,
11
16
  TsiEnergyReport,
12
17
  Zone,
13
18
  )
14
- from .telemetry import VALUE_KEY_T2, parse_telemetry_points
19
+ from .telemetry import (
20
+ VALUE_KEY_T2,
21
+ EnergySummary,
22
+ filter_telemetry_points,
23
+ parse_telemetry_points,
24
+ summarise_energy,
25
+ )
15
26
 
16
27
  __all__ = [
17
28
  "DimplexControl",
29
+ "TokenBundle",
18
30
  "Hub",
19
31
  "Zone",
20
32
  "Appliance",
21
33
  "ApplianceStatus",
22
34
  "ApplianceModeSettings",
35
+ "ApplianceModeFlag",
36
+ "TimerMode",
37
+ "TimerModeSettings",
38
+ "TimerPeriod",
23
39
  "AutomaticProvisioning",
24
40
  "TsiEnergyReport",
25
41
  "parse_telemetry_points",
42
+ "filter_telemetry_points",
43
+ "summarise_energy",
44
+ "EnergySummary",
26
45
  "VALUE_KEY_T2",
27
46
  "DimplexError",
28
47
  "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,13 @@ 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
+ TimerMode,
24
28
  TimerModeSettings,
29
+ TimerPeriod,
25
30
  TsiEnergyReport,
26
31
  UserContext,
27
32
  Zone,
@@ -30,11 +35,15 @@ from .models import (
30
35
  _LOGGER = logging.getLogger(__name__)
31
36
 
32
37
  # 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.
38
+ # endpoint is paginated server-side; with IncludePreviousPeriod the cloud
39
+ # often returns the full history regardless, so this mainly bounds the
40
+ # "current window" half of the request.
35
41
  DEFAULT_TSI_REPORT_DAYS = 30
36
42
  DEFAULT_TSI_INTERVAL = "01:00:00"
37
43
 
44
+ # Default boost length when the caller does not specify one (minutes).
45
+ DEFAULT_BOOST_MINUTES = 60
46
+
38
47
 
39
48
  def _iso_utc_days_ago(days: int) -> str:
40
49
  """Return an ISO-8601 UTC timestamp ``days`` before now (no microseconds)."""
@@ -51,15 +60,24 @@ class DimplexControl:
51
60
  refresh_token: str | None = None,
52
61
  access_token: str | None = None,
53
62
  expires_at: float = 0,
63
+ *,
64
+ token_bundle: TokenBundle | None = None,
54
65
  ):
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]
66
+ """Initialize the client.
67
+
68
+ Prefer ``token_bundle`` for new code. The individual token kwargs remain
69
+ supported for backwards compatibility.
70
+ """
71
+ if token_bundle is not None:
72
+ token_data: dict[str, Any] | TokenBundle = token_bundle
73
+ else:
74
+ token_data = {}
75
+ if refresh_token:
76
+ token_data["refresh_token"] = refresh_token
77
+ if access_token:
78
+ token_data["access_token"] = access_token
79
+ if expires_at:
80
+ token_data["expires_at"] = expires_at
63
81
 
64
82
  self._session = session
65
83
  self.auth = AuthManager(session, token_data)
@@ -69,7 +87,15 @@ class DimplexControl:
69
87
  """Check if authenticated."""
70
88
  return self.auth.is_authenticated
71
89
 
72
- async def _request(self, method: str, endpoint: str, **kwargs) -> dict:
90
+ def export_tokens(self) -> TokenBundle:
91
+ """Return the current auth token snapshot."""
92
+ return self.auth.export_tokens()
93
+
94
+ def apply_tokens(self, bundle: TokenBundle | dict[str, Any]) -> None:
95
+ """Replace in-memory auth tokens."""
96
+ self.auth.apply_tokens(bundle)
97
+
98
+ async def _request(self, method: str, endpoint: str, **kwargs: Any) -> Any:
73
99
  """Make an authenticated request."""
74
100
  token = await self.auth.get_access_token()
75
101
  headers = kwargs.pop("headers", {})
@@ -109,76 +135,164 @@ class DimplexControl:
109
135
  async def get_hubs(self) -> list[Hub]:
110
136
  """Get all hubs for the user."""
111
137
  data = await self._request("GET", "/Hubs/GetUserHubs")
112
- # Log analysis shows list of objects
113
- return [Hub(**h) for h in data]
138
+ return [Hub.model_validate(h) for h in data]
114
139
 
115
140
  async def get_hub_zones(self, hub_id: str) -> list[Zone]:
116
141
  """Get zones and appliances for a hub."""
117
142
  data = await self._request("GET", "/Zones/GetZonesAndAppliancesForHubId", params={"HubId": hub_id})
118
- return [Zone(**z) for z in data]
143
+ return [Zone.model_validate(z) for z in data]
119
144
 
120
145
  async def get_zone(self, hub_id: str, zone_id: str) -> Zone:
121
146
  """Get details for a specific zone."""
122
147
  payload = {"HubId": hub_id, "ZoneId": zone_id}
123
148
  data = await self._request("POST", "/Zones/GetZone", json=payload)
124
- return Zone(**data)
149
+ return Zone.model_validate(data)
125
150
 
126
151
  async def get_appliance_overview(self, hub_id: str, appliance_ids: list[str]) -> list[ApplianceStatus]:
127
- """Get status overview for specific appliances."""
152
+ """Get status overview for specific appliances.
153
+
154
+ When appliances are offline the cloud may return an empty list with
155
+ HTTP 200. That is success — use :meth:`get_appliance_overview_map` if
156
+ you need a stable id → status mapping.
157
+ """
128
158
  payload = {"HubId": hub_id, "ApplianceIds": appliance_ids}
129
159
  data = await self._request("POST", "/RemoteControl/GetApplianceOverview", json=payload)
130
- return [ApplianceStatus(**item) for item in data]
160
+ if not data:
161
+ return []
162
+ return [ApplianceStatus.model_validate(item) for item in data]
163
+
164
+ async def get_appliance_overview_map(
165
+ self, hub_id: str, appliance_ids: list[str]
166
+ ) -> dict[str, ApplianceStatus | None]:
167
+ """Return a map of appliance id → status (``None`` when missing)."""
168
+ overview = await self.get_appliance_overview(hub_id, appliance_ids)
169
+ by_id = {status.ApplianceId: status for status in overview}
170
+ return {appliance_id: by_id.get(appliance_id) for appliance_id in appliance_ids}
131
171
 
132
172
  async def get_user_context(self) -> UserContext:
133
173
  """Get user profile/context."""
134
174
  data = await self._request("GET", "/Identity/GetUserContext")
135
- return UserContext(**data)
175
+ return UserContext.model_validate(data)
136
176
 
137
177
  async def get_appliance_features(self, hub_id: str, appliance_id: str) -> TimerModeSettings:
138
178
  """Get timer details (and mode) for an appliance."""
139
- # In the logs, this endpoint returns current mode and timer profiles
140
179
  payload = {
141
180
  "HubId": hub_id,
142
181
  "ApplianceId": appliance_id,
143
- "TimerMode": 0, # Required field in request, value doesn't seem to matter for fetching?
182
+ "TimerMode": 0, # Required field in request; value ignored on read
144
183
  }
145
184
  data = await self._request("POST", "/RemoteControl/GetTimerModeDetailsForAppliance", json=payload)
146
- return TimerModeSettings(**data)
185
+ return TimerModeSettings.model_validate(data)
147
186
 
148
- async def set_mode(self, hub_id: str, appliance_id: str, mode: int) -> None:
149
- """Set the operation mode.
187
+ async def set_mode(self, hub_id: str, appliance_id: str, mode: int | TimerMode) -> None:
188
+ """Set the timer / operation mode.
150
189
 
151
- Modes (inferred):
152
- 0: Manual? (User Timer?)
153
- 1: Manual
154
- 2: Frost Protection
155
- 3: Off?
190
+ See :class:`~dimplex_controller.models.TimerMode` for known values.
156
191
  """
157
- # We need to fetch current settings first to preserve other fields if API requires full object
158
192
  current = await self.get_appliance_features(hub_id, appliance_id)
159
- current.TimerMode = mode
160
-
161
- payload = {"TimerModeSettings": current.dict()}
193
+ current.TimerMode = int(mode)
162
194
 
195
+ payload = {"TimerModeSettings": current.model_dump(mode="json")}
163
196
  await self._request("POST", "/RemoteControl/SetTimerMode", json=payload)
164
197
 
165
198
  async def set_target_temperature(self, hub_id: str, appliance_id: str, temp: float) -> None:
166
- """Set target temperature.
199
+ """Set the target / comfort temperature for an appliance.
200
+
201
+ The Dimplex cloud stores setpoints on timer periods. This method:
167
202
 
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.
203
+ 1. Loads the current timer configuration.
204
+ 2. Updates every period's temperature (preserving day/time windows).
205
+ 3. If no periods exist (common for some Quantum configs), installs a
206
+ full-week 00:00–23:59 schedule at the requested temperature in
207
+ manual mode so the cloud has a concrete setpoint to apply.
208
+
209
+ This matches the reverse-engineered mobile-app approach of rewriting
210
+ the active schedule rather than a dedicated "set temperature" RPC.
171
211
  """
172
- _LOGGER.warning("set_target_temperature not fully implemented - requires complex schedule manipulation")
173
- pass
212
+ current = await self.get_appliance_features(hub_id, appliance_id)
213
+
214
+ if current.TimerPeriods:
215
+ for period in current.TimerPeriods:
216
+ period.Temperature = float(temp)
217
+ else:
218
+ current.TimerMode = int(TimerMode.MANUAL)
219
+ current.TimerPeriods = [
220
+ TimerPeriod(
221
+ DayOfWeek=day,
222
+ StartTime="00:00:00",
223
+ EndTime="23:59:59",
224
+ Temperature=float(temp),
225
+ )
226
+ for day in range(7)
227
+ ]
228
+
229
+ payload = {"TimerModeSettings": current.model_dump(mode="json")}
230
+ await self._request("POST", "/RemoteControl/SetTimerMode", json=payload)
174
231
 
175
232
  async def set_appliance_mode(
176
233
  self, hub_id: str, appliance_ids: list[str], mode_settings: ApplianceModeSettings
177
234
  ) -> None:
178
235
  """Set appliance mode (Boost, Away, etc.)."""
179
- payload = {"Settings": mode_settings.dict(), "HubId": hub_id, "ApplianceIds": appliance_ids}
236
+ payload = {
237
+ "Settings": mode_settings.model_dump(mode="json"),
238
+ "HubId": hub_id,
239
+ "ApplianceIds": appliance_ids,
240
+ }
180
241
  await self._request("POST", "/RemoteControl/SetApplianceMode", json=payload)
181
242
 
243
+ async def set_boost(
244
+ self,
245
+ hub_id: str,
246
+ appliance_ids: list[str],
247
+ *,
248
+ temperature: float,
249
+ duration_minutes: int = DEFAULT_BOOST_MINUTES,
250
+ enable: bool = True,
251
+ ) -> None:
252
+ """Enable or disable Boost for one or more appliances.
253
+
254
+ The mobile app uses ``ApplianceModes=16`` with ``Status=1`` (on) /
255
+ ``Status=0`` (off). ``Time`` carries the boost duration in minutes.
256
+ """
257
+ settings = ApplianceModeSettings(
258
+ ApplianceModes=int(ApplianceModeFlag.BOOST),
259
+ Status=1 if enable else 0,
260
+ Temperature=float(temperature),
261
+ Time=int(duration_minutes) if enable else 0,
262
+ )
263
+ await self.set_appliance_mode(hub_id, appliance_ids, settings)
264
+
265
+ async def clear_boost(self, hub_id: str, appliance_ids: list[str], *, temperature: float = 21.0) -> None:
266
+ """Disable Boost for the given appliances."""
267
+ await self.set_boost(hub_id, appliance_ids, temperature=temperature, duration_minutes=0, enable=False)
268
+
269
+ async def set_away(
270
+ self,
271
+ hub_id: str,
272
+ appliance_ids: list[str],
273
+ *,
274
+ temperature: float,
275
+ enable: bool = True,
276
+ number_of_days: int = 0,
277
+ ) -> None:
278
+ """Enable or disable Away mode.
279
+
280
+ Uses ``ApplianceModes=32`` (best-effort; confirmed via status-frame
281
+ pairing with Away* fields). Prefer verifying on a live appliance
282
+ after firmware updates.
283
+ """
284
+ settings = ApplianceModeSettings(
285
+ ApplianceModes=int(ApplianceModeFlag.AWAY),
286
+ Status=1 if enable else 0,
287
+ Temperature=float(temperature),
288
+ NumberOfDays=int(number_of_days) if enable else 0,
289
+ )
290
+ await self.set_appliance_mode(hub_id, appliance_ids, settings)
291
+
292
+ async def clear_away(self, hub_id: str, appliance_ids: list[str], *, temperature: float = 16.0) -> None:
293
+ """Disable Away mode for the given appliances."""
294
+ await self.set_away(hub_id, appliance_ids, temperature=temperature, enable=False)
295
+
182
296
  async def set_eco_start(self, hub_id: str, appliance_ids: list[str], enable: bool) -> None:
183
297
  """Enable/Disable EcoStart."""
184
298
  payload = {"Enable": enable, "HubId": hub_id, "ApplianceIds": appliance_ids}
@@ -196,19 +310,24 @@ class DimplexControl:
196
310
  interval: str = DEFAULT_TSI_INTERVAL,
197
311
  start_date: str | None = None,
198
312
  end_date: str | None = None,
199
- include_previous_period: bool = False,
313
+ include_previous_period: bool = True,
200
314
  days_back: int = DEFAULT_TSI_REPORT_DAYS,
201
315
  ) -> TsiEnergyReport:
202
316
  """Fetch the Time Series Insights energy report for a hub.
203
317
 
204
318
  Returns a :class:`~dimplex_controller.models.TsiEnergyReport`. Each
205
319
  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.
320
+ :func:`dimplex_controller.telemetry.parse_telemetry_points` and
321
+ :func:`dimplex_controller.telemetry.summarise_energy` to normalise
322
+ and aggregate.
208
323
 
209
324
  When the hub has no metered appliances (e.g. non-QRAD heaters, or a
210
325
  quiet summer hub) the per-appliance lists come back empty; that is
211
326
  treated as success, not an error.
327
+
328
+ Note: with ``include_previous_period=True`` (the default) the cloud
329
+ frequently returns the **full available daily history**, not only the
330
+ ``days_back`` window. Filter client-side for daily/lifetime totals.
212
331
  """
213
332
  if start_date is None:
214
333
  start_date = _iso_utc_days_ago(days_back)
@@ -1,9 +1,39 @@
1
+ from __future__ import annotations
2
+
1
3
  import json
2
4
  from datetime import datetime, time
5
+ from enum import IntEnum, IntFlag
3
6
 
4
7
  from pydantic import BaseModel, Field, field_validator
5
8
 
6
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
+ """
31
+
32
+ NONE = 0
33
+ BOOST = 16
34
+ AWAY = 32
35
+
36
+
7
37
  class AutomaticProvisioning(BaseModel):
8
38
  """Parsed contents of ``ProductModelExtensions.AUTOMATIC_PROVISIONING``.
9
39
 
@@ -137,7 +167,7 @@ class ApplianceStatus(BaseModel):
137
167
  StatusTwo: int | None = None
138
168
  ApplianceModes: int | None = None
139
169
  RoomTemperature: float | None = None
140
- ActiveSetPointTemperature: int | None = None
170
+ ActiveSetPointTemperature: float | None = None
141
171
  NormalTemperature: float | None = None
142
172
  AwayDateTime: str | None = None
143
173
  AwayTemperature: float | None = None
@@ -154,6 +184,27 @@ class ApplianceStatus(BaseModel):
154
184
  ErrorCode: str | None = None
155
185
  WarningCode: str | None = None
156
186
 
187
+ @property
188
+ def mode_flags(self) -> ApplianceModeFlag:
189
+ """Return ``ApplianceModes`` as a typed flag set."""
190
+ if self.ApplianceModes is None:
191
+ return ApplianceModeFlag.NONE
192
+ return ApplianceModeFlag(self.ApplianceModes)
193
+
194
+ @property
195
+ def is_boost_active(self) -> bool:
196
+ """True when boost duration is set or the boost mode bit is present."""
197
+ if self.BoostDuration is not None and self.BoostDuration > 0:
198
+ return True
199
+ return bool(self.mode_flags & ApplianceModeFlag.BOOST)
200
+
201
+ @property
202
+ def is_away_active(self) -> bool:
203
+ """True when away fields indicate an active away session."""
204
+ if self.AwayDateTime and self.AwayDateTime not in ("", "0001-01-01T00:00:00"):
205
+ return True
206
+ return bool(self.mode_flags & ApplianceModeFlag.AWAY)
207
+
157
208
 
158
209
  class ApplianceModeSettings(BaseModel):
159
210
  """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.7.0"
4
4
  description = "Python client for Dimplex heating controllers (GDHV IoT)"
5
5
  authors = ["Kieran Roper"]
6
6
  license = "MIT"
@@ -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