dimplex-controller 0.2.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dimplex-controller
3
- Version: 0.2.1
3
+ Version: 0.4.0
4
4
  Summary: Python client for Dimplex heating controllers (GDHV IoT)
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -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",
@@ -0,0 +1,329 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import re
5
+ import time
6
+ from urllib.parse import parse_qs, urlparse
7
+
8
+ import aiohttp
9
+
10
+ from .const import (
11
+ AUTH_URL,
12
+ B2C_POLICY,
13
+ CLIENT_ID,
14
+ HTTP_OK,
15
+ REDIRECT_URI,
16
+ SCOPE,
17
+ )
18
+ from .exceptions import DimplexAuthError
19
+
20
+ _LOGGER = logging.getLogger(__name__)
21
+
22
+
23
+ class AuthManager:
24
+ """Manages authentication for Dimplex Control."""
25
+
26
+ def __init__(self, session: aiohttp.ClientSession, token_data: dict | None = None):
27
+ """Initialize the auth manager."""
28
+ 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
32
+
33
+ @property
34
+ def is_authenticated(self) -> bool:
35
+ """Check if we have a valid access token."""
36
+ return self._access_token is not None and time.time() < self._expires_at
37
+
38
+ async def get_access_token(self) -> str:
39
+ """Get a valid access token, refreshing if necessary."""
40
+ if not self._refresh_token:
41
+ raise DimplexAuthError("No refresh token available. User must authenticate first.")
42
+
43
+ if self.is_authenticated:
44
+ return self._access_token # type: ignore[return-value]
45
+
46
+ # Token expired or missing, try refresh
47
+ await self.refresh_tokens()
48
+ return self._access_token # type: ignore[return-value]
49
+
50
+ async def refresh_tokens(self) -> None:
51
+ """Refresh the access token using the refresh token."""
52
+ _LOGGER.debug("Refreshing access token")
53
+ payload = {
54
+ "client_id": CLIENT_ID,
55
+ "grant_type": "refresh_token",
56
+ "refresh_token": self._refresh_token,
57
+ "scope": SCOPE,
58
+ "client_info": "1",
59
+ }
60
+
61
+ async with self._session.post(f"{AUTH_URL}/token", data=payload) as resp:
62
+ if resp.status != HTTP_OK:
63
+ text = await resp.text()
64
+ _LOGGER.error("Failed to refresh token: %s", text)
65
+ raise DimplexAuthError(f"Failed to refresh token: {resp.status} - {text}")
66
+
67
+ data = await resp.json()
68
+ self._update_tokens(data)
69
+
70
+ def _update_tokens(self, data: dict) -> None:
71
+ """Update internal token state from API response."""
72
+ self._access_token = data.get("access_token")
73
+ self._refresh_token = data.get("refresh_token")
74
+ expires_in = data.get("expires_in", 3600)
75
+ self._expires_at = time.time() + expires_in - 60 # Buffer 60s
76
+
77
+ def get_login_url(self) -> str:
78
+ """Generate the login URL for the user to visit."""
79
+ # Note: This is a simplified URL generation.
80
+ # In a real app, we might need state, nonce, code_challenge (PKCE).
81
+ # Based on logs, iOS app uses standard OAuth2.
82
+ # Constructing a URL for manual copy-paste might be tricky if it strictly requires a custom scheme redirect.
83
+ # But we can try the standard authorize endpoint.
84
+
85
+ params = {
86
+ "client_id": CLIENT_ID,
87
+ "response_type": "code",
88
+ "redirect_uri": REDIRECT_URI,
89
+ "scope": SCOPE,
90
+ "response_mode": "query",
91
+ }
92
+ from urllib.parse import urlencode
93
+
94
+ return f"{AUTH_URL}/authorize?{urlencode(params)}"
95
+
96
+ async def exchange_code(self, code: str) -> None:
97
+ """Exchange authorization code for tokens."""
98
+ payload = {
99
+ "client_id": CLIENT_ID,
100
+ "grant_type": "authorization_code",
101
+ "code": code,
102
+ "redirect_uri": REDIRECT_URI,
103
+ "scope": SCOPE,
104
+ }
105
+
106
+ _LOGGER.info(f"Exchanging code for tokens at {AUTH_URL}/token")
107
+ client_id = payload["client_id"]
108
+ redirect_uri = payload["redirect_uri"]
109
+ code_preview = code[:10]
110
+ _LOGGER.info(f"Payload: client_id={client_id}, redirect_uri={redirect_uri}, " f"code={code_preview}...")
111
+
112
+ async with self._session.post(f"{AUTH_URL}/token", data=payload) as resp:
113
+ _LOGGER.info(f"Token exchange response status: {resp.status}")
114
+ if resp.status != HTTP_OK:
115
+ text = await resp.text()
116
+ _LOGGER.error(f"Token exchange failed: {text}")
117
+ raise DimplexAuthError(f"Failed to exchange code: {text}")
118
+
119
+ data = await resp.json()
120
+ self._update_tokens(data)
121
+
122
+ @staticmethod
123
+ def _build_cookie_header(cookie_jar, url: str) -> str:
124
+ """Build an unquoted Cookie header from an aiohttp cookie jar.
125
+
126
+ Python's http.cookies wraps values containing +, /, or = in
127
+ double-quotes, but Azure AD B2C expects raw unquoted values.
128
+ """
129
+ filtered = cookie_jar.filter_cookies(url)
130
+ return "; ".join(f"{m.key}={m.value}" for m in filtered.values())
131
+
132
+ @staticmethod
133
+ def _parse_b2c_login_page(html: str, page_url: str) -> dict:
134
+ """Extract B2C form fields from the login page HTML.
135
+
136
+ Returns a dict with csrf, tx, p, post_url, confirmed_url.
137
+ Raises DimplexAuthError if required fields cannot be found.
138
+ """
139
+ csrf_match = re.search(r'"csrf"\s*:\s*"([^"]+)"', html)
140
+ if not csrf_match:
141
+ raise DimplexAuthError("Could not find CSRF token in B2C login page")
142
+ csrf = csrf_match.group(1)
143
+
144
+ tx_match = re.search(r'"transId"\s*:\s*"([^"]+)"', html)
145
+ if not tx_match:
146
+ raise DimplexAuthError("Could not find transId in B2C login page")
147
+ tx = tx_match.group(1)
148
+
149
+ # Build base URL by stripping the authorize endpoint.
150
+ # The B2C login page URL contains /tfp/{tenant}/{policy}/oauth2/v2.0/authorize
151
+ # and may redirect to /{tenant}/{policy}/oauth2/v2.0/authorize
152
+ if "/oauth2/v2.0/authorize" in page_url:
153
+ base_url = page_url.split("/oauth2/v2.0/authorize")[0]
154
+ else:
155
+ parsed = urlparse(page_url)
156
+ base_url = f"{parsed.scheme}://{parsed.netloc}" f"/gdhvb2c.onmicrosoft.com/{B2C_POLICY}"
157
+
158
+ return {
159
+ "csrf": csrf,
160
+ "tx": tx,
161
+ "p": B2C_POLICY,
162
+ "post_url": f"{base_url}/SelfAsserted?tx={tx}&p={B2C_POLICY}",
163
+ "confirmed_url": (f"{base_url}/api/CombinedSigninAndSignup/confirmed"),
164
+ }
165
+
166
+ async def headless_login(self, email: str, password: str) -> None:
167
+ """Perform a headless login via Azure AD B2C to obtain tokens.
168
+
169
+ Uses direct HTTP credential submission so users don't need to
170
+ manually extract auth codes from browser network traffic.
171
+ """
172
+ jar = aiohttp.CookieJar(unsafe=True)
173
+ start_url = self.get_login_url()
174
+
175
+ async with aiohttp.ClientSession(cookie_jar=jar) as session:
176
+ # Step 1: GET the auth URI, follow redirects to B2C login page
177
+ _LOGGER.debug("Fetching B2C login page: %s", start_url)
178
+ async with session.get(start_url, allow_redirects=True) as resp:
179
+ login_html = await resp.text()
180
+ page_url = str(resp.url)
181
+ if resp.status != HTTP_OK:
182
+ raise DimplexAuthError(f"B2C login page returned HTTP {resp.status}")
183
+
184
+ # Step 2: Parse the login page for CSRF, transaction ID, policy
185
+ fields = self._parse_b2c_login_page(login_html, page_url)
186
+ _LOGGER.debug(
187
+ "Parsed B2C login page: csrf=%s... tx=%s... p=%s",
188
+ fields["csrf"][:16],
189
+ fields["tx"][:40],
190
+ fields["p"],
191
+ )
192
+
193
+ # Step 3: POST credentials to SelfAsserted endpoint
194
+ post_data = {
195
+ "request_type": "RESPONSE",
196
+ "email": email,
197
+ "password": password,
198
+ }
199
+ parsed_page = urlparse(page_url)
200
+ origin = f"{parsed_page.scheme}://{parsed_page.netloc}"
201
+ post_headers = {
202
+ "X-CSRF-TOKEN": fields["csrf"],
203
+ "X-Requested-With": "XMLHttpRequest",
204
+ "Referer": page_url,
205
+ "Origin": origin,
206
+ "Accept": "application/json, text/javascript, */*; q=0.01",
207
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
208
+ }
209
+
210
+ # Build an unquoted Cookie header — aiohttp wraps values
211
+ # containing +/= in double-quotes, but B2C requires raw values.
212
+ cookie_header = self._build_cookie_header(jar, fields["post_url"])
213
+ post_headers["Cookie"] = cookie_header
214
+ _LOGGER.debug("Submitting credentials to %s", fields["post_url"])
215
+
216
+ # Use DummyCookieJar so POST response cookies aren't
217
+ # re-injected with quoted values on the next request.
218
+ async with aiohttp.ClientSession(
219
+ cookie_jar=aiohttp.DummyCookieJar(),
220
+ ) as raw_session:
221
+ async with raw_session.post(
222
+ fields["post_url"],
223
+ data=post_data,
224
+ headers=post_headers,
225
+ allow_redirects=False,
226
+ ) as resp:
227
+ body = await resp.text()
228
+ if resp.status != HTTP_OK:
229
+ raise DimplexAuthError(f"Credential submission returned HTTP {resp.status}")
230
+ try:
231
+ resp_data = json.loads(body)
232
+ if str(resp_data.get("status")) == "400":
233
+ raise DimplexAuthError("Invalid email or password")
234
+ except json.JSONDecodeError:
235
+ pass
236
+
237
+ # Merge POST response cookies into the cookie header
238
+ cookies: dict[str, str] = {}
239
+ for part in cookie_header.split("; "):
240
+ if "=" in part:
241
+ n, v = part.split("=", 1)
242
+ cookies[n] = v
243
+ for raw_sc in resp.headers.getall("Set-Cookie", []):
244
+ sc_pair = raw_sc.split(";", 1)[0]
245
+ if "=" in sc_pair:
246
+ n, v = sc_pair.split("=", 1)
247
+ cookies[n] = v
248
+ cookie_header = "; ".join(f"{n}={v}" for n, v in cookies.items())
249
+
250
+ # Step 4: GET the confirmed endpoint and follow redirects
251
+ confirmed_qs = (
252
+ f"rememberMe=false" f"&csrf_token={fields['csrf']}" f"&tx={fields['tx']}" f"&p={fields['p']}"
253
+ )
254
+ next_url: str = fields["confirmed_url"] + "?" + confirmed_qs
255
+ confirmed_headers = {"Cookie": cookie_header}
256
+
257
+ for _ in range(20): # max redirect hops
258
+ _LOGGER.debug("Following redirect: %s", next_url[:120])
259
+ async with raw_session.get(
260
+ next_url,
261
+ headers=confirmed_headers,
262
+ allow_redirects=False,
263
+ ) as resp:
264
+ resp_body = await resp.text()
265
+ if resp.status in (301, 302, 303, 307, 308):
266
+ location = resp.headers.get("Location", "")
267
+ if not location:
268
+ raise DimplexAuthError("Redirect without Location header")
269
+ if location.startswith(REDIRECT_URI) and (
270
+ len(location) == len(REDIRECT_URI) or location[len(REDIRECT_URI)] in ("?", "/")
271
+ ):
272
+ _LOGGER.debug(
273
+ "Captured redirect with code: %s...",
274
+ location[:120],
275
+ )
276
+ parsed = urlparse(location)
277
+ query = parse_qs(parsed.query)
278
+ code = query.get("code", [""])[0]
279
+ if not code:
280
+ raise DimplexAuthError("Redirect URL missing auth code")
281
+ await self.exchange_code(code)
282
+ return
283
+ if not location.startswith("http"):
284
+ location = (
285
+ f"{parsed_page.scheme}://{parsed_page.netloc}" + location
286
+ if location.startswith("/")
287
+ else location
288
+ )
289
+ next_url = location
290
+ continue
291
+ if resp.status == HTTP_OK:
292
+ redirect_match = re.search(
293
+ rf"({re.escape(REDIRECT_URI)}\?[^\s\"'<]+)",
294
+ resp_body,
295
+ )
296
+ if redirect_match:
297
+ parsed = urlparse(redirect_match.group(1))
298
+ query = parse_qs(parsed.query)
299
+ code = query.get("code", [""])[0]
300
+ if code:
301
+ await self.exchange_code(code)
302
+ return
303
+ raise DimplexAuthError("Reached 200 response without finding redirect URL")
304
+ raise DimplexAuthError(f"Unexpected HTTP {resp.status} during redirect chain")
305
+
306
+ 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,5 +1,5 @@
1
1
  import logging
2
- from typing import Dict, List, Optional
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: Optional[str] = None,
38
- access_token: Optional[str] = None,
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) -> Dict:
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) -> List[Hub]:
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) -> List[Zone]:
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: List[str]) -> List[ApplianceStatus]:
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: List[str], mode_settings: ApplianceModeSettings
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: List[str], enable: bool) -> None:
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: List[str], enable: bool) -> None:
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
+ )
@@ -19,3 +19,4 @@ HEADER_DEVICE_MODEL = "iPhone18,1"
19
19
  CLIENT_ID = "6c983ca3-506e-4933-8993-0e18e6a24bbd"
20
20
  SCOPE = "https://gdhvb2c.onmicrosoft.com/Mobile/read offline_access openid profile"
21
21
  REDIRECT_URI = "msal6c983ca3-506e-4933-8993-0e18e6a24bbd://auth/"
22
+ B2C_POLICY = "B2C_1A_DimplexControlSignupSignin"
@@ -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.2.1"
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,304 +0,0 @@
1
- import json
2
- import logging
3
- import os
4
- import re
5
- import time
6
- from typing import Dict, Optional
7
- from urllib.parse import parse_qs, urlencode, urlparse
8
-
9
- import aiohttp
10
- from bs4 import BeautifulSoup
11
-
12
- from .const import (
13
- AUTH_URL,
14
- CLIENT_ID,
15
- HTTP_OK,
16
- REDIRECT_URI,
17
- SCOPE,
18
- )
19
- from .exceptions import DimplexAuthError
20
-
21
- _LOGGER = logging.getLogger(__name__)
22
-
23
-
24
- class AuthManager:
25
- """Manages authentication for Dimplex Control."""
26
-
27
- def __init__(self, session: aiohttp.ClientSession, token_data: Optional[Dict] = None):
28
- """Initialize the auth manager."""
29
- self._session = session
30
- self._access_token: Optional[str] = token_data.get("access_token") if token_data else None
31
- self._refresh_token: Optional[str] = token_data.get("refresh_token") if token_data else None
32
- self._expires_at: float = token_data.get("expires_at", 0) if token_data else 0
33
-
34
- @property
35
- def is_authenticated(self) -> bool:
36
- """Check if we have a valid access token."""
37
- return self._access_token is not None and time.time() < self._expires_at
38
-
39
- async def get_access_token(self) -> str:
40
- """Get a valid access token, refreshing if necessary."""
41
- if not self._refresh_token:
42
- raise DimplexAuthError("No refresh token available. User must authenticate first.")
43
-
44
- if self.is_authenticated:
45
- return self._access_token
46
-
47
- # Token expired or missing, try refresh
48
- await self.refresh_tokens()
49
- return self._access_token
50
-
51
- async def refresh_tokens(self) -> None:
52
- """Refresh the access token using the refresh token."""
53
- _LOGGER.debug("Refreshing access token")
54
- payload = {
55
- "client_id": CLIENT_ID,
56
- "grant_type": "refresh_token",
57
- "refresh_token": self._refresh_token,
58
- "scope": SCOPE,
59
- "client_info": "1",
60
- }
61
-
62
- async with self._session.post(f"{AUTH_URL}/token", data=payload) as resp:
63
- if resp.status != HTTP_OK:
64
- text = await resp.text()
65
- _LOGGER.error("Failed to refresh token: %s", text)
66
- raise DimplexAuthError(f"Failed to refresh token: {resp.status} - {text}")
67
-
68
- data = await resp.json()
69
- self._update_tokens(data)
70
-
71
- def _update_tokens(self, data: Dict) -> None:
72
- """Update internal token state from API response."""
73
- self._access_token = data.get("access_token")
74
- self._refresh_token = data.get("refresh_token")
75
- expires_in = data.get("expires_in", 3600)
76
- self._expires_at = time.time() + expires_in - 60 # Buffer 60s
77
-
78
- def get_login_url(self) -> str:
79
- """Generate the login URL for the user to visit."""
80
- # Note: This is a simplified URL generation.
81
- # In a real app, we might need state, nonce, code_challenge (PKCE).
82
- # Based on logs, iOS app uses standard OAuth2.
83
- # Constructing a URL for manual copy-paste might be tricky if it strictly requires a custom scheme redirect.
84
- # But we can try the standard authorize endpoint.
85
-
86
- params = {
87
- "client_id": CLIENT_ID,
88
- "response_type": "code",
89
- "redirect_uri": REDIRECT_URI,
90
- "scope": SCOPE,
91
- "response_mode": "query",
92
- }
93
- from urllib.parse import urlencode
94
-
95
- return f"{AUTH_URL}/authorize?{urlencode(params)}"
96
-
97
- async def exchange_code(self, code: str) -> None:
98
- """Exchange authorization code for tokens."""
99
- payload = {
100
- "client_id": CLIENT_ID,
101
- "grant_type": "authorization_code",
102
- "code": code,
103
- "redirect_uri": REDIRECT_URI,
104
- "scope": SCOPE,
105
- }
106
-
107
- _LOGGER.info(f"Exchanging code for tokens at {AUTH_URL}/token")
108
- client_id = payload["client_id"]
109
- redirect_uri = payload["redirect_uri"]
110
- code_preview = code[:10]
111
- _LOGGER.info(f"Payload: client_id={client_id}, redirect_uri={redirect_uri}, " f"code={code_preview}...")
112
-
113
- async with self._session.post(f"{AUTH_URL}/token", data=payload) as resp:
114
- _LOGGER.info(f"Token exchange response status: {resp.status}")
115
- if resp.status != HTTP_OK:
116
- text = await resp.text()
117
- _LOGGER.error(f"Token exchange failed: {text}")
118
- raise DimplexAuthError(f"Failed to exchange code: {text}")
119
-
120
- data = await resp.json()
121
- self._update_tokens(data)
122
-
123
- async def headless_login(self, email, password) -> None:
124
- """Perform a headless login to obtain tokens."""
125
- # 1. Get the login page
126
- params = {
127
- "client_id": CLIENT_ID,
128
- "response_type": "code",
129
- "redirect_uri": REDIRECT_URI,
130
- "scope": SCOPE,
131
- "response_mode": "query",
132
- }
133
- start_url = f"{AUTH_URL}/authorize?{urlencode(params)}"
134
- _LOGGER.debug(f"Fetching login page: {start_url}")
135
-
136
- async with self._session.get(start_url) as resp:
137
- html = await resp.text()
138
- final_url = str(resp.url)
139
-
140
- # 2. Extract SETTINGS and CSRF
141
- match = re.search(r"SETTINGS\s*=\s*({.*?});", html, re.DOTALL | re.MULTILINE)
142
- if not match:
143
- raise DimplexAuthError("Could not find SETTINGS in login page")
144
-
145
- try:
146
- settings = json.loads(match.group(1))
147
- except json.JSONDecodeError:
148
- raise DimplexAuthError("Failed to parse SETTINGS JSON from login page")
149
-
150
- csrf_token = settings.get("csrf")
151
- trans_id = settings.get("transId")
152
-
153
- if not csrf_token or not trans_id:
154
- raise DimplexAuthError("Missing csrf or transId in login page SETTINGS")
155
-
156
- # 3. Construct SelfAsserted URL
157
- if "/oauth2/v2.0/authorize" in final_url:
158
- base_url = final_url.split("/oauth2/v2.0/authorize")[0]
159
- else:
160
- base_url = "https://gdhvb2c.b2clogin.com/gdhvb2c.onmicrosoft.com/B2C_1A_DimplexControlSignupSignin"
161
-
162
- post_url = f"{base_url}/SelfAsserted"
163
-
164
- params = {
165
- "tx": trans_id,
166
- "p": "B2C_1A_DimplexControlSignupSignin",
167
- }
168
-
169
- post_url_with_params = f"{post_url}?{urlencode(params)}"
170
-
171
- # 4. Submit Credentials
172
- # Extract hidden fields from the form to ensure we aren't missing anything
173
- soup = BeautifulSoup(html, "html.parser")
174
- form = soup.find("form", {"id": "localAccountForm"}) or soup.find("form")
175
-
176
- form_data = {}
177
- if form:
178
- for input_tag in form.find_all("input"):
179
- name = input_tag.get("name")
180
- value = input_tag.get("value", "")
181
- if name:
182
- form_data[name] = value
183
-
184
- # Overwrite with credentials
185
- form_data.update(
186
- {
187
- "request_type": "RESPONSE",
188
- "email": email,
189
- "password": password,
190
- }
191
- )
192
-
193
- headers = {
194
- "X-CSRF-TOKEN": csrf_token,
195
- "X-Requested-With": "XMLHttpRequest",
196
- "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
197
- "Origin": "https://gdhvb2c.b2clogin.com",
198
- "Referer": final_url,
199
- }
200
-
201
- _LOGGER.debug(f"Submitting credentials to {post_url_with_params}")
202
- # _LOGGER.debug(f"Form data: {form_data}") # Security risk to log password
203
-
204
- async with self._session.post(post_url_with_params, data=form_data, headers=headers) as resp:
205
- resp_text = await resp.text()
206
- _LOGGER.debug(f"Login response status: {resp.status}")
207
-
208
- try:
209
- resp_json = json.loads(resp_text)
210
- _LOGGER.debug(f"Login response JSON: {resp_json}")
211
- except json.JSONDecodeError:
212
- _LOGGER.debug(f"Login response Text: {resp_text}")
213
- raise DimplexAuthError(f"Login response was not valid JSON: {resp_text[:100]}")
214
-
215
- if resp_json.get("status") != "200":
216
- status = resp_json.get("status")
217
- message = resp_json.get("message") or resp_json.get("reason", "Unknown reason")
218
- raise DimplexAuthError(f"Login failed: {status} - {message}")
219
-
220
- # 5. Follow the 'Confirmed' step to get the actual code
221
- # After a successful SelfAsserted, we usually need to make a GET to
222
- # the 'CombinedSigninAndSignup' or similar endpoint to finalize the
223
- # flow and get the redirect to our app with the code.
224
- # Or, sometimes the SelfAsserted response sets a cookie and we just
225
- # need to hit the authorize endpoint again.
226
-
227
- # Let's try hitting the original authorize URL again (or the one we were redirected to).
228
- # Since cookies are in the session, it should now redirect us to the app with the code.
229
-
230
- _LOGGER.debug("Credentials accepted. Fetching authorize URL again to get code.")
231
-
232
- # We need to allow redirects to capture the final msauth:// url
233
- # aiohttp generic session checks redirects.
234
- # But since the scheme is custom (msal...), aiohttp might throw an error or stop.
235
-
236
- try:
237
- async with self._session.get(start_url, allow_redirects=True) as resp:
238
- # If we are here, it means we didn't crash on custom scheme yet,
239
- # or we are at a page that directs us.
240
- # Check history
241
- pass
242
- except aiohttp.ClientError:
243
- # This might happen if the redirect schema is not http/https
244
- # We can inspect the error or just capture it from the history if possible
245
- pass
246
- except Exception:
247
- # If it tries to redirect to msal..., it might fail if aiohttp doesn't support it.
248
- # We can disable redirects and follow manually to catch it.
249
- pass
250
-
251
- # Manual redirect following to catch custom scheme
252
- current_url = start_url
253
- code = None
254
-
255
- for _ in range(10): # Max redirects
256
- async with self._session.get(current_url, allow_redirects=False) as resp:
257
- if resp.status in (302, 303, 301):
258
- location = resp.headers.get("Location")
259
- if not location:
260
- break
261
-
262
- if location.startswith(REDIRECT_URI) or "code=" in location:
263
- # Success!
264
- _LOGGER.debug(f"Found redirect with code: {location}")
265
- # Extract code
266
- parsed = urlparse(location)
267
- query = parse_qs(parsed.query)
268
- code = query.get("code", [""])[0]
269
- break
270
-
271
- current_url = location
272
- else:
273
- break
274
-
275
- if not code:
276
- raise DimplexAuthError(
277
- "Failed to obtain auth code after successful login. Redirect URI might have changed."
278
- )
279
-
280
- _LOGGER.debug(f"Got code: {code[:10]}...")
281
- await self.exchange_code(code)
282
-
283
- def save_tokens(self, file_path: str) -> None:
284
- """Save current tokens to a JSON file."""
285
- data = {
286
- "access_token": self._access_token,
287
- "refresh_token": self._refresh_token,
288
- "expires_at": self._expires_at,
289
- }
290
- with open(file_path, "w") as f:
291
- json.dump(data, f, indent=2)
292
- _LOGGER.info("Tokens saved to %s", file_path)
293
-
294
- @classmethod
295
- def load_tokens(cls, file_path: str) -> Optional[Dict]:
296
- """Load tokens from a JSON file."""
297
- if not os.path.exists(file_path):
298
- return None
299
- try:
300
- with open(file_path, "r") as f:
301
- return json.load(f)
302
- except Exception as e:
303
- _LOGGER.error("Failed to load tokens from %s: %s", file_path, e)
304
- return None
@@ -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