dimplex-controller 0.3.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dimplex_controller-0.3.0 → dimplex_controller-0.4.0}/PKG-INFO +1 -1
- {dimplex_controller-0.3.0 → dimplex_controller-0.4.0}/dimplex_controller/__init__.py +4 -1
- {dimplex_controller-0.3.0 → dimplex_controller-0.4.0}/dimplex_controller/auth.py +8 -9
- {dimplex_controller-0.3.0 → dimplex_controller-0.4.0}/dimplex_controller/client.py +61 -11
- dimplex_controller-0.4.0/dimplex_controller/models.py +117 -0
- dimplex_controller-0.4.0/dimplex_controller/telemetry.py +133 -0
- {dimplex_controller-0.3.0 → dimplex_controller-0.4.0}/pyproject.toml +15 -2
- dimplex_controller-0.3.0/dimplex_controller/models.py +0 -99
- {dimplex_controller-0.3.0 → dimplex_controller-0.4.0}/LICENSE +0 -0
- {dimplex_controller-0.3.0 → dimplex_controller-0.4.0}/README.md +0 -0
- {dimplex_controller-0.3.0 → dimplex_controller-0.4.0}/dimplex_controller/const.py +0 -0
- {dimplex_controller-0.3.0 → dimplex_controller-0.4.0}/dimplex_controller/exceptions.py +0 -0
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from .client import DimplexControl
|
|
4
4
|
from .exceptions import DimplexApiError, DimplexAuthError, DimplexConnectionError, DimplexError
|
|
5
|
-
from .models import Appliance, ApplianceModeSettings, ApplianceStatus, Hub, Zone
|
|
5
|
+
from .models import Appliance, ApplianceModeSettings, ApplianceStatus, Hub, TsiEnergyReport, Zone
|
|
6
|
+
from .telemetry import parse_telemetry_points
|
|
6
7
|
|
|
7
8
|
__all__ = [
|
|
8
9
|
"DimplexControl",
|
|
@@ -11,6 +12,8 @@ __all__ = [
|
|
|
11
12
|
"Appliance",
|
|
12
13
|
"ApplianceStatus",
|
|
13
14
|
"ApplianceModeSettings",
|
|
15
|
+
"TsiEnergyReport",
|
|
16
|
+
"parse_telemetry_points",
|
|
14
17
|
"DimplexError",
|
|
15
18
|
"DimplexApiError",
|
|
16
19
|
"DimplexAuthError",
|
|
@@ -3,7 +3,6 @@ import logging
|
|
|
3
3
|
import os
|
|
4
4
|
import re
|
|
5
5
|
import time
|
|
6
|
-
from typing import Dict, Optional
|
|
7
6
|
from urllib.parse import parse_qs, urlparse
|
|
8
7
|
|
|
9
8
|
import aiohttp
|
|
@@ -24,11 +23,11 @@ _LOGGER = logging.getLogger(__name__)
|
|
|
24
23
|
class AuthManager:
|
|
25
24
|
"""Manages authentication for Dimplex Control."""
|
|
26
25
|
|
|
27
|
-
def __init__(self, session: aiohttp.ClientSession, token_data:
|
|
26
|
+
def __init__(self, session: aiohttp.ClientSession, token_data: dict | None = None):
|
|
28
27
|
"""Initialize the auth manager."""
|
|
29
28
|
self._session = session
|
|
30
|
-
self._access_token:
|
|
31
|
-
self._refresh_token:
|
|
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
|
|
32
31
|
self._expires_at: float = token_data.get("expires_at", 0) if token_data else 0
|
|
33
32
|
|
|
34
33
|
@property
|
|
@@ -42,11 +41,11 @@ class AuthManager:
|
|
|
42
41
|
raise DimplexAuthError("No refresh token available. User must authenticate first.")
|
|
43
42
|
|
|
44
43
|
if self.is_authenticated:
|
|
45
|
-
return self._access_token
|
|
44
|
+
return self._access_token # type: ignore[return-value]
|
|
46
45
|
|
|
47
46
|
# Token expired or missing, try refresh
|
|
48
47
|
await self.refresh_tokens()
|
|
49
|
-
return self._access_token
|
|
48
|
+
return self._access_token # type: ignore[return-value]
|
|
50
49
|
|
|
51
50
|
async def refresh_tokens(self) -> None:
|
|
52
51
|
"""Refresh the access token using the refresh token."""
|
|
@@ -68,7 +67,7 @@ class AuthManager:
|
|
|
68
67
|
data = await resp.json()
|
|
69
68
|
self._update_tokens(data)
|
|
70
69
|
|
|
71
|
-
def _update_tokens(self, data:
|
|
70
|
+
def _update_tokens(self, data: dict) -> None:
|
|
72
71
|
"""Update internal token state from API response."""
|
|
73
72
|
self._access_token = data.get("access_token")
|
|
74
73
|
self._refresh_token = data.get("refresh_token")
|
|
@@ -318,12 +317,12 @@ class AuthManager:
|
|
|
318
317
|
_LOGGER.info("Tokens saved to %s", file_path)
|
|
319
318
|
|
|
320
319
|
@classmethod
|
|
321
|
-
def load_tokens(cls, file_path: str) ->
|
|
320
|
+
def load_tokens(cls, file_path: str) -> dict | None:
|
|
322
321
|
"""Load tokens from a JSON file."""
|
|
323
322
|
if not os.path.exists(file_path):
|
|
324
323
|
return None
|
|
325
324
|
try:
|
|
326
|
-
with open(file_path
|
|
325
|
+
with open(file_path) as f:
|
|
327
326
|
return json.load(f)
|
|
328
327
|
except Exception as e:
|
|
329
328
|
_LOGGER.error("Failed to load tokens from %s: %s", file_path, e)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
from
|
|
2
|
+
from datetime import datetime, timedelta, timezone
|
|
3
3
|
|
|
4
4
|
import aiohttp
|
|
5
5
|
|
|
@@ -21,12 +21,25 @@ from .models import (
|
|
|
21
21
|
ApplianceStatus,
|
|
22
22
|
Hub,
|
|
23
23
|
TimerModeSettings,
|
|
24
|
+
TsiEnergyReport,
|
|
24
25
|
UserContext,
|
|
25
26
|
Zone,
|
|
26
27
|
)
|
|
27
28
|
|
|
28
29
|
_LOGGER = logging.getLogger(__name__)
|
|
29
30
|
|
|
31
|
+
# Default lookback when the caller does not pin a start date. The energy
|
|
32
|
+
# endpoint is paginated server-side; 30 days is a reasonable default that
|
|
33
|
+
# keeps the response small while still giving the caller a meaningful window.
|
|
34
|
+
DEFAULT_TSI_REPORT_DAYS = 30
|
|
35
|
+
DEFAULT_TSI_INTERVAL = "01:00:00"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _iso_utc_days_ago(days: int) -> str:
|
|
39
|
+
"""Return an ISO-8601 UTC timestamp ``days`` before now (no microseconds)."""
|
|
40
|
+
dt = datetime.now(timezone.utc) - timedelta(days=days)
|
|
41
|
+
return dt.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
42
|
+
|
|
30
43
|
|
|
31
44
|
class DimplexControl:
|
|
32
45
|
"""Main client for Dimplex Control API."""
|
|
@@ -34,8 +47,8 @@ class DimplexControl:
|
|
|
34
47
|
def __init__(
|
|
35
48
|
self,
|
|
36
49
|
session: aiohttp.ClientSession,
|
|
37
|
-
refresh_token:
|
|
38
|
-
access_token:
|
|
50
|
+
refresh_token: str | None = None,
|
|
51
|
+
access_token: str | None = None,
|
|
39
52
|
expires_at: float = 0,
|
|
40
53
|
):
|
|
41
54
|
"""Initialize the client."""
|
|
@@ -45,7 +58,7 @@ class DimplexControl:
|
|
|
45
58
|
if access_token:
|
|
46
59
|
token_data["access_token"] = access_token
|
|
47
60
|
if expires_at:
|
|
48
|
-
token_data["expires_at"] = expires_at
|
|
61
|
+
token_data["expires_at"] = expires_at # type: ignore[assignment]
|
|
49
62
|
|
|
50
63
|
self._session = session
|
|
51
64
|
self.auth = AuthManager(session, token_data)
|
|
@@ -55,7 +68,7 @@ class DimplexControl:
|
|
|
55
68
|
"""Check if authenticated."""
|
|
56
69
|
return self.auth.is_authenticated
|
|
57
70
|
|
|
58
|
-
async def _request(self, method: str, endpoint: str, **kwargs) ->
|
|
71
|
+
async def _request(self, method: str, endpoint: str, **kwargs) -> dict:
|
|
59
72
|
"""Make an authenticated request."""
|
|
60
73
|
token = await self.auth.get_access_token()
|
|
61
74
|
headers = kwargs.pop("headers", {})
|
|
@@ -92,13 +105,13 @@ class DimplexControl:
|
|
|
92
105
|
_LOGGER.error("Connection error during API request: %s", e)
|
|
93
106
|
raise DimplexConnectionError(f"Connection error: {e}") from e
|
|
94
107
|
|
|
95
|
-
async def get_hubs(self) ->
|
|
108
|
+
async def get_hubs(self) -> list[Hub]:
|
|
96
109
|
"""Get all hubs for the user."""
|
|
97
110
|
data = await self._request("GET", "/Hubs/GetUserHubs")
|
|
98
111
|
# Log analysis shows list of objects
|
|
99
112
|
return [Hub(**h) for h in data]
|
|
100
113
|
|
|
101
|
-
async def get_hub_zones(self, hub_id: str) ->
|
|
114
|
+
async def get_hub_zones(self, hub_id: str) -> list[Zone]:
|
|
102
115
|
"""Get zones and appliances for a hub."""
|
|
103
116
|
data = await self._request("GET", "/Zones/GetZonesAndAppliancesForHubId", params={"HubId": hub_id})
|
|
104
117
|
return [Zone(**z) for z in data]
|
|
@@ -109,7 +122,7 @@ class DimplexControl:
|
|
|
109
122
|
data = await self._request("POST", "/Zones/GetZone", json=payload)
|
|
110
123
|
return Zone(**data)
|
|
111
124
|
|
|
112
|
-
async def get_appliance_overview(self, hub_id: str, appliance_ids:
|
|
125
|
+
async def get_appliance_overview(self, hub_id: str, appliance_ids: list[str]) -> list[ApplianceStatus]:
|
|
113
126
|
"""Get status overview for specific appliances."""
|
|
114
127
|
payload = {"HubId": hub_id, "ApplianceIds": appliance_ids}
|
|
115
128
|
data = await self._request("POST", "/RemoteControl/GetApplianceOverview", json=payload)
|
|
@@ -159,18 +172,55 @@ class DimplexControl:
|
|
|
159
172
|
pass
|
|
160
173
|
|
|
161
174
|
async def set_appliance_mode(
|
|
162
|
-
self, hub_id: str, appliance_ids:
|
|
175
|
+
self, hub_id: str, appliance_ids: list[str], mode_settings: ApplianceModeSettings
|
|
163
176
|
) -> None:
|
|
164
177
|
"""Set appliance mode (Boost, Away, etc.)."""
|
|
165
178
|
payload = {"Settings": mode_settings.dict(), "HubId": hub_id, "ApplianceIds": appliance_ids}
|
|
166
179
|
await self._request("POST", "/RemoteControl/SetApplianceMode", json=payload)
|
|
167
180
|
|
|
168
|
-
async def set_eco_start(self, hub_id: str, appliance_ids:
|
|
181
|
+
async def set_eco_start(self, hub_id: str, appliance_ids: list[str], enable: bool) -> None:
|
|
169
182
|
"""Enable/Disable EcoStart."""
|
|
170
183
|
payload = {"Enable": enable, "HubId": hub_id, "ApplianceIds": appliance_ids}
|
|
171
184
|
await self._request("POST", "/RemoteControl/SetEcoStart", json=payload)
|
|
172
185
|
|
|
173
|
-
async def set_open_window_detection(self, hub_id: str, appliance_ids:
|
|
186
|
+
async def set_open_window_detection(self, hub_id: str, appliance_ids: list[str], enable: bool) -> None:
|
|
174
187
|
"""Enable/Disable Open Window Detection."""
|
|
175
188
|
payload = {"Enable": enable, "HubId": hub_id, "ApplianceIds": appliance_ids}
|
|
176
189
|
await self._request("POST", "/RemoteControl/SetOpenWindowDetection", json=payload)
|
|
190
|
+
|
|
191
|
+
async def get_tsi_energy_report(
|
|
192
|
+
self,
|
|
193
|
+
hub_id: str | None = None,
|
|
194
|
+
report_type: int = 1,
|
|
195
|
+
interval: str = DEFAULT_TSI_INTERVAL,
|
|
196
|
+
start_date: str | None = None,
|
|
197
|
+
include_previous_period: bool = False,
|
|
198
|
+
days_back: int = DEFAULT_TSI_REPORT_DAYS,
|
|
199
|
+
) -> TsiEnergyReport:
|
|
200
|
+
"""Fetch the Time Series Insights energy report for a hub.
|
|
201
|
+
|
|
202
|
+
Returns a :class:`~dimplex_controller.models.TsiEnergyReport`. The
|
|
203
|
+
cloud response is the same regardless of ``hub_id`` (the field is
|
|
204
|
+
informational), so ``hub_id`` is only required to populate the
|
|
205
|
+
returned model. Each per-appliance list is left as the raw payload —
|
|
206
|
+
use :func:`dimplex_controller.telemetry.parse_telemetry_points` to
|
|
207
|
+
normalise the points into ``(timestamp, value)`` tuples.
|
|
208
|
+
|
|
209
|
+
When the hub has no metered appliances (e.g. non-QRAD heaters, or a
|
|
210
|
+
quiet summer hub) the per-appliance lists come back empty; that is
|
|
211
|
+
treated as success, not an error.
|
|
212
|
+
"""
|
|
213
|
+
if start_date is None:
|
|
214
|
+
start_date = _iso_utc_days_ago(days_back)
|
|
215
|
+
|
|
216
|
+
payload = {
|
|
217
|
+
"TsiReportType": report_type,
|
|
218
|
+
"Interval": interval,
|
|
219
|
+
"StartDate": start_date,
|
|
220
|
+
"IncludePreviousPeriod": include_previous_period,
|
|
221
|
+
}
|
|
222
|
+
data = await self._request("POST", "/Reports/GetTsiEnergyReportDataForHub", json=payload)
|
|
223
|
+
return TsiEnergyReport(
|
|
224
|
+
HubId=(hub_id or data.get("HubId", "")),
|
|
225
|
+
ApplianceTelemetryData=data.get("ApplianceTelemetryData", {}) or {},
|
|
226
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from datetime import datetime, time
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Appliance(BaseModel):
|
|
7
|
+
ApplianceId: str
|
|
8
|
+
ApplianceType: str
|
|
9
|
+
ApplianceModel: str | None = None
|
|
10
|
+
ZoneId: str
|
|
11
|
+
FriendlyName: str
|
|
12
|
+
ZoneName: str
|
|
13
|
+
Icon: str | None = None
|
|
14
|
+
IconColor: str | None = None
|
|
15
|
+
InstallationDate: datetime | None = None
|
|
16
|
+
HasConnectivity: bool | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Zone(BaseModel):
|
|
20
|
+
ZoneId: str
|
|
21
|
+
ZoneName: str
|
|
22
|
+
HubId: str
|
|
23
|
+
ZoneType: str
|
|
24
|
+
Appliances: list[Appliance] = Field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Hub(BaseModel):
|
|
28
|
+
HubId: str
|
|
29
|
+
Name: str | None = Field(None, alias="HubName")
|
|
30
|
+
FriendlyName: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TimerPeriod(BaseModel):
|
|
34
|
+
DayOfWeek: int
|
|
35
|
+
StartTime: str # Kept as str for easy JSON serialization
|
|
36
|
+
EndTime: str
|
|
37
|
+
Temperature: float
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def start_time_obj(self) -> time:
|
|
41
|
+
return datetime.strptime(self.StartTime, "%H:%M:%S").time()
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def end_time_obj(self) -> time:
|
|
45
|
+
return datetime.strptime(self.EndTime, "%H:%M:%S").time()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TimerModeSettings(BaseModel):
|
|
49
|
+
HubId: str
|
|
50
|
+
ApplianceId: str
|
|
51
|
+
TimerMode: int
|
|
52
|
+
TimerPeriods: list[TimerPeriod] = Field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class UserContext(BaseModel):
|
|
56
|
+
Id: str
|
|
57
|
+
EmailAddress: str | None = None
|
|
58
|
+
Name: str | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ApplianceStatus(BaseModel):
|
|
62
|
+
"""Represents the real-time status of an appliance as returned by GetApplianceOverview."""
|
|
63
|
+
|
|
64
|
+
HubId: str
|
|
65
|
+
ApplianceId: str
|
|
66
|
+
ZoneId: str
|
|
67
|
+
StatusTwo: int | None = None
|
|
68
|
+
ApplianceModes: int | None = None
|
|
69
|
+
RoomTemperature: float | None = None
|
|
70
|
+
ActiveSetPointTemperature: int | None = None
|
|
71
|
+
NormalTemperature: float | None = None
|
|
72
|
+
AwayDateTime: str | None = None
|
|
73
|
+
AwayTemperature: float | None = None
|
|
74
|
+
BoostDuration: int | None = None
|
|
75
|
+
BoostTemperature: float | None = None
|
|
76
|
+
OpenWindowEnabled: bool | None = None
|
|
77
|
+
EcoStartEnabled: bool | None = None
|
|
78
|
+
SetbackEnabled: bool | None = None
|
|
79
|
+
SetbackEnabledInStatusFrame: bool | None = None
|
|
80
|
+
SetbackTemperature: float | None = None
|
|
81
|
+
ComfortStatus: bool | None = None
|
|
82
|
+
AvailableHotWater: float | None = None
|
|
83
|
+
LockStatus: int | None = None
|
|
84
|
+
ErrorCode: str | None = None
|
|
85
|
+
WarningCode: str | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ApplianceModeSettings(BaseModel):
|
|
89
|
+
"""Settings used to control appliance modes like Boost or Away."""
|
|
90
|
+
|
|
91
|
+
ApplianceModes: int
|
|
92
|
+
Status: int
|
|
93
|
+
Temperature: float = 23.0
|
|
94
|
+
Time: int = 0
|
|
95
|
+
Date: str = "0001-01-01T00:00:00"
|
|
96
|
+
StatusTwo: int = 0
|
|
97
|
+
NumberOfDays: int = 0
|
|
98
|
+
Frequency: int = 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TsiEnergyReport(BaseModel):
|
|
102
|
+
"""Response from `POST /Reports/GetTsiEnergyReportDataForHub`.
|
|
103
|
+
|
|
104
|
+
The cloud returns one telemetry bucket per appliance registered to the hub.
|
|
105
|
+
Each bucket is a list of points whose individual shape is undocumented and
|
|
106
|
+
appears to vary by firmware; entries are normalised by
|
|
107
|
+
:func:`dimplex_controller.telemetry.parse_telemetry_points` so callers do
|
|
108
|
+
not have to know the wire format.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
HubId: str
|
|
112
|
+
ApplianceTelemetryData: dict[str, list] = Field(default_factory=dict)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# `TsiReportType` integer values understood by the API. We do not know the
|
|
116
|
+
# canonical mapping; these are observed in traffic captures.
|
|
117
|
+
TSI_REPORT_TYPE_ENERGY = 1
|
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
# Keys the cloud has been observed using, in priority order. The first match
|
|
17
|
+
# wins. Case-insensitive lookup is done by lowercasing the dict before
|
|
18
|
+
# scanning, so callers do not have to worry about casing.
|
|
19
|
+
_TIMESTAMP_KEYS = (
|
|
20
|
+
"timestamp",
|
|
21
|
+
"time",
|
|
22
|
+
"ts",
|
|
23
|
+
"t",
|
|
24
|
+
"datetime",
|
|
25
|
+
"date",
|
|
26
|
+
"from",
|
|
27
|
+
"start",
|
|
28
|
+
)
|
|
29
|
+
_VALUE_KEYS = (
|
|
30
|
+
"value",
|
|
31
|
+
"kwh",
|
|
32
|
+
"energy",
|
|
33
|
+
"consumption",
|
|
34
|
+
"energykwh",
|
|
35
|
+
"amount",
|
|
36
|
+
"v",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _coerce_timestamp(raw: Any) -> datetime | None:
|
|
41
|
+
"""Best-effort conversion of ``raw`` into a ``datetime``.
|
|
42
|
+
|
|
43
|
+
Accepts ``datetime`` instances, ISO-8601 strings, and Unix epoch numbers
|
|
44
|
+
(seconds or milliseconds). Returns ``None`` if the value cannot be parsed.
|
|
45
|
+
"""
|
|
46
|
+
if raw is None:
|
|
47
|
+
return None
|
|
48
|
+
if isinstance(raw, datetime):
|
|
49
|
+
return raw
|
|
50
|
+
if isinstance(raw, int | float):
|
|
51
|
+
ts = float(raw)
|
|
52
|
+
if ts > 1e12:
|
|
53
|
+
ts = ts / 1000.0
|
|
54
|
+
try:
|
|
55
|
+
return datetime.fromtimestamp(ts, tz=timezone.utc)
|
|
56
|
+
except (OverflowError, OSError, ValueError):
|
|
57
|
+
return None
|
|
58
|
+
if isinstance(raw, str):
|
|
59
|
+
text = raw.strip()
|
|
60
|
+
if not text:
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
64
|
+
except ValueError:
|
|
65
|
+
return None
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _coerce_value(raw: Any) -> float | None:
|
|
70
|
+
"""Best-effort conversion of ``raw`` into a ``float``."""
|
|
71
|
+
if raw is None:
|
|
72
|
+
return None
|
|
73
|
+
if isinstance(raw, bool):
|
|
74
|
+
return None
|
|
75
|
+
if isinstance(raw, int | float):
|
|
76
|
+
return float(raw)
|
|
77
|
+
if isinstance(raw, str):
|
|
78
|
+
try:
|
|
79
|
+
return float(raw.strip())
|
|
80
|
+
except ValueError:
|
|
81
|
+
return None
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _iter_items(point: Any) -> Iterable[tuple[str, Any]] | None:
|
|
86
|
+
"""Yield ``(key, value)`` pairs from a dict-like ``point``.
|
|
87
|
+
|
|
88
|
+
Returns ``None`` for non-dict points so the caller can fall through to the
|
|
89
|
+
list / scalar branches.
|
|
90
|
+
"""
|
|
91
|
+
if not isinstance(point, dict):
|
|
92
|
+
return None
|
|
93
|
+
lower = {str(k).lower(): v for k, v in point.items()}
|
|
94
|
+
return ((k, lower.get(k)) for k in (*_TIMESTAMP_KEYS, *_VALUE_KEYS))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parse_telemetry_points(points: Any) -> list[tuple[datetime | None, float]]:
|
|
98
|
+
"""Normalise a list of telemetry points into ``(timestamp, value)`` pairs.
|
|
99
|
+
|
|
100
|
+
Each entry in ``points`` may be:
|
|
101
|
+
|
|
102
|
+
* a ``dict`` with one of the recognised timestamp/value keys
|
|
103
|
+
* a 2-element ``[timestamp, value]`` list or tuple
|
|
104
|
+
* a bare scalar (treated as a cumulative value at an unknown timestamp)
|
|
105
|
+
|
|
106
|
+
Unparseable entries are silently skipped. The order of the input list is
|
|
107
|
+
preserved.
|
|
108
|
+
"""
|
|
109
|
+
if not isinstance(points, list):
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
out: list[tuple[datetime | None, float]] = []
|
|
113
|
+
for point in points:
|
|
114
|
+
ts: datetime | None = None
|
|
115
|
+
value: float | None = None
|
|
116
|
+
|
|
117
|
+
items = _iter_items(point)
|
|
118
|
+
if items is not None:
|
|
119
|
+
for key, raw in items:
|
|
120
|
+
if key in _TIMESTAMP_KEYS and ts is None:
|
|
121
|
+
ts = _coerce_timestamp(raw)
|
|
122
|
+
elif key in _VALUE_KEYS and value is None:
|
|
123
|
+
value = _coerce_value(raw)
|
|
124
|
+
elif isinstance(point, list | tuple) and len(point) == 2:
|
|
125
|
+
ts = _coerce_timestamp(point[0])
|
|
126
|
+
value = _coerce_value(point[1])
|
|
127
|
+
else:
|
|
128
|
+
value = _coerce_value(point)
|
|
129
|
+
|
|
130
|
+
if value is None:
|
|
131
|
+
continue
|
|
132
|
+
out.append((ts, value))
|
|
133
|
+
return out
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "dimplex-controller"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "Python client for Dimplex heating controllers (GDHV IoT)"
|
|
5
5
|
authors = ["Kieran Roper"]
|
|
6
6
|
license = "MIT"
|
|
@@ -18,6 +18,7 @@ classifiers = [
|
|
|
18
18
|
"Programming Language :: Python :: 3.10",
|
|
19
19
|
"Programming Language :: Python :: 3.11",
|
|
20
20
|
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
21
22
|
"Topic :: Home Automation",
|
|
22
23
|
]
|
|
23
24
|
packages = [{ include = "dimplex_controller" }]
|
|
@@ -31,6 +32,7 @@ python-dotenv = "^1.2.1"
|
|
|
31
32
|
[tool.poetry.group.dev.dependencies]
|
|
32
33
|
pytest = "^8.0.0"
|
|
33
34
|
pytest-asyncio = "^0.23.0"
|
|
35
|
+
pytest-cov = "^6.0.0"
|
|
34
36
|
aresponses = "^3.0.0"
|
|
35
37
|
ruff = "^0.3.0"
|
|
36
38
|
pre-commit = "^3.6.0"
|
|
@@ -41,7 +43,18 @@ line-length = 120
|
|
|
41
43
|
target-version = "py310"
|
|
42
44
|
|
|
43
45
|
[tool.ruff.lint]
|
|
44
|
-
select = ["E", "F", "I", "W"]
|
|
46
|
+
select = ["E", "F", "I", "W", "UP", "B", "SIM"]
|
|
47
|
+
# E501 (line length) is handled by the formatter; long strings/comments are fine.
|
|
48
|
+
ignore = ["E501"]
|
|
49
|
+
|
|
50
|
+
[tool.mypy]
|
|
51
|
+
# Loose by design — the library is mostly untyped and a strict pass is out of
|
|
52
|
+
# scope for the CI modernisation. The two auth.py return-narrowing issues and
|
|
53
|
+
# the client.py expires_at assignment are pre-existing and either fixed in
|
|
54
|
+
# place or # type: ignore-d; remove when a full typing pass lands.
|
|
55
|
+
python_version = "3.10"
|
|
56
|
+
files = ["dimplex_controller"]
|
|
57
|
+
ignore_missing_imports = true
|
|
45
58
|
|
|
46
59
|
[build-system]
|
|
47
60
|
requires = ["poetry-core"]
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
from datetime import datetime, time
|
|
2
|
-
from typing import List, Optional
|
|
3
|
-
|
|
4
|
-
from pydantic import BaseModel, Field
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class Appliance(BaseModel):
|
|
8
|
-
ApplianceId: str
|
|
9
|
-
ApplianceType: str
|
|
10
|
-
ApplianceModel: Optional[str] = None
|
|
11
|
-
ZoneId: str
|
|
12
|
-
FriendlyName: str
|
|
13
|
-
ZoneName: str
|
|
14
|
-
Icon: Optional[str] = None
|
|
15
|
-
IconColor: Optional[str] = None
|
|
16
|
-
InstallationDate: Optional[datetime] = None
|
|
17
|
-
HasConnectivity: Optional[bool] = None
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class Zone(BaseModel):
|
|
21
|
-
ZoneId: str
|
|
22
|
-
ZoneName: str
|
|
23
|
-
HubId: str
|
|
24
|
-
ZoneType: str
|
|
25
|
-
Appliances: List[Appliance] = Field(default_factory=list)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class Hub(BaseModel):
|
|
29
|
-
HubId: str
|
|
30
|
-
Name: Optional[str] = Field(None, alias="HubName")
|
|
31
|
-
FriendlyName: Optional[str] = None
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class TimerPeriod(BaseModel):
|
|
35
|
-
DayOfWeek: int
|
|
36
|
-
StartTime: str # Kept as str for easy JSON serialization
|
|
37
|
-
EndTime: str
|
|
38
|
-
Temperature: float
|
|
39
|
-
|
|
40
|
-
@property
|
|
41
|
-
def start_time_obj(self) -> time:
|
|
42
|
-
return datetime.strptime(self.StartTime, "%H:%M:%S").time()
|
|
43
|
-
|
|
44
|
-
@property
|
|
45
|
-
def end_time_obj(self) -> time:
|
|
46
|
-
return datetime.strptime(self.EndTime, "%H:%M:%S").time()
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
class TimerModeSettings(BaseModel):
|
|
50
|
-
HubId: str
|
|
51
|
-
ApplianceId: str
|
|
52
|
-
TimerMode: int
|
|
53
|
-
TimerPeriods: List[TimerPeriod] = Field(default_factory=list)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
class UserContext(BaseModel):
|
|
57
|
-
Id: str
|
|
58
|
-
EmailAddress: Optional[str] = None
|
|
59
|
-
Name: Optional[str] = None
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
class ApplianceStatus(BaseModel):
|
|
63
|
-
"""Represents the real-time status of an appliance as returned by GetApplianceOverview."""
|
|
64
|
-
|
|
65
|
-
HubId: str
|
|
66
|
-
ApplianceId: str
|
|
67
|
-
ZoneId: str
|
|
68
|
-
StatusTwo: Optional[int] = None
|
|
69
|
-
ApplianceModes: Optional[int] = None
|
|
70
|
-
RoomTemperature: Optional[float] = None
|
|
71
|
-
ActiveSetPointTemperature: Optional[int] = None
|
|
72
|
-
NormalTemperature: Optional[float] = None
|
|
73
|
-
AwayDateTime: Optional[str] = None
|
|
74
|
-
AwayTemperature: Optional[float] = None
|
|
75
|
-
BoostDuration: Optional[int] = None
|
|
76
|
-
BoostTemperature: Optional[float] = None
|
|
77
|
-
OpenWindowEnabled: Optional[bool] = None
|
|
78
|
-
EcoStartEnabled: Optional[bool] = None
|
|
79
|
-
SetbackEnabled: Optional[bool] = None
|
|
80
|
-
SetbackEnabledInStatusFrame: Optional[bool] = None
|
|
81
|
-
SetbackTemperature: Optional[float] = None
|
|
82
|
-
ComfortStatus: Optional[bool] = None
|
|
83
|
-
AvailableHotWater: Optional[float] = None
|
|
84
|
-
LockStatus: Optional[int] = None
|
|
85
|
-
ErrorCode: Optional[str] = None
|
|
86
|
-
WarningCode: Optional[str] = None
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
class ApplianceModeSettings(BaseModel):
|
|
90
|
-
"""Settings used to control appliance modes like Boost or Away."""
|
|
91
|
-
|
|
92
|
-
ApplianceModes: int
|
|
93
|
-
Status: int
|
|
94
|
-
Temperature: float = 23.0
|
|
95
|
-
Time: int = 0
|
|
96
|
-
Date: str = "0001-01-01T00:00:00"
|
|
97
|
-
StatusTwo: int = 0
|
|
98
|
-
NumberOfDays: int = 0
|
|
99
|
-
Frequency: int = 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|