pycaldera 0.1.dev0__py3-none-any.whl
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.
- pycaldera/__init__.py +22 -0
- pycaldera/__meta__.py +12 -0
- pycaldera/async_client.py +495 -0
- pycaldera/exceptions.py +31 -0
- pycaldera/models.py +261 -0
- pycaldera-0.1.dev0.dist-info/LICENSE +21 -0
- pycaldera-0.1.dev0.dist-info/METADATA +198 -0
- pycaldera-0.1.dev0.dist-info/RECORD +10 -0
- pycaldera-0.1.dev0.dist-info/WHEEL +5 -0
- pycaldera-0.1.dev0.dist-info/top_level.txt +1 -0
pycaldera/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Python client for Caldera Spa Connexion API."""
|
|
2
|
+
|
|
3
|
+
from .async_client import AsyncCalderaClient
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
CalderaError,
|
|
7
|
+
ConnectionError,
|
|
8
|
+
InvalidParameterError,
|
|
9
|
+
SpaControlError,
|
|
10
|
+
)
|
|
11
|
+
from .models import LiveSettings
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AsyncCalderaClient",
|
|
15
|
+
"LiveSettings",
|
|
16
|
+
"CalderaError",
|
|
17
|
+
"AuthenticationError",
|
|
18
|
+
"ConnectionError",
|
|
19
|
+
"SpaControlError",
|
|
20
|
+
"InvalidParameterError",
|
|
21
|
+
]
|
|
22
|
+
__version__ = "0.1.0"
|
pycaldera/__meta__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# `name` is the name of the package as used for `pip install package`
|
|
2
|
+
name = "pycaldera"
|
|
3
|
+
# `path` is the name of the package for `import package`
|
|
4
|
+
path = name.lower().replace("-", "_").replace(" ", "_")
|
|
5
|
+
# Your version number should follow https://python.org/dev/peps/pep-0440 and
|
|
6
|
+
# https://semver.org
|
|
7
|
+
version = "0.1.dev0"
|
|
8
|
+
author = "Mark Watson"
|
|
9
|
+
author_email = "markwatson@cantab.net"
|
|
10
|
+
description = "Unofficial Python client for Caldera Spa API" # One-liner
|
|
11
|
+
url = "https://github.com/mwatson2/pycaldera" # Add your GitHub repo URL
|
|
12
|
+
license = "MIT" # See https://choosealicense.com
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""Async client implementation for Caldera Spa API."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from asyncio import AbstractEventLoop
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
import pydantic
|
|
10
|
+
from aiohttp import ClientError, ClientSession
|
|
11
|
+
|
|
12
|
+
from .exceptions import (
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
ConnectionError,
|
|
15
|
+
InvalidParameterError,
|
|
16
|
+
SpaControlError,
|
|
17
|
+
)
|
|
18
|
+
from .models import (
|
|
19
|
+
AuthResponse,
|
|
20
|
+
LiveSettings,
|
|
21
|
+
LiveSettingsResponse,
|
|
22
|
+
SpaResponseDato,
|
|
23
|
+
SpaStatusResponse,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Pump speed constants
|
|
29
|
+
PUMP_OFF = 0
|
|
30
|
+
PUMP_LOW = 1
|
|
31
|
+
PUMP_HIGH = 2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AsyncCalderaClient:
|
|
35
|
+
"""Async client for interacting with Caldera Spa API."""
|
|
36
|
+
|
|
37
|
+
BASE_URL = "https://connectedspa.watkinsmfg.com/connextion"
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
email: str,
|
|
42
|
+
password: str,
|
|
43
|
+
timeout: float = 10.0,
|
|
44
|
+
debug: bool = False,
|
|
45
|
+
session: Optional[ClientSession] = None,
|
|
46
|
+
loop: Optional[AbstractEventLoop] = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Initialize the async Caldera client.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
email: Email address for authentication
|
|
52
|
+
password: Password for authentication
|
|
53
|
+
timeout: Request timeout in seconds
|
|
54
|
+
debug: Enable debug logging
|
|
55
|
+
session: Optional aiohttp ClientSession to use
|
|
56
|
+
loop: Optional asyncio event loop to use
|
|
57
|
+
"""
|
|
58
|
+
self.email = email
|
|
59
|
+
self.password = password
|
|
60
|
+
self.timeout = timeout
|
|
61
|
+
self._session = session
|
|
62
|
+
self._loop = loop
|
|
63
|
+
self._owns_session = False
|
|
64
|
+
self._token: Optional[str] = None
|
|
65
|
+
self._spa_id: Optional[int] = None
|
|
66
|
+
self._hna_number: Optional[str] = None
|
|
67
|
+
|
|
68
|
+
if debug:
|
|
69
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
70
|
+
else:
|
|
71
|
+
logging.basicConfig(level=logging.INFO)
|
|
72
|
+
|
|
73
|
+
async def __aenter__(self) -> "AsyncCalderaClient":
|
|
74
|
+
"""Enter async context manager."""
|
|
75
|
+
if self._session is None:
|
|
76
|
+
self._session = aiohttp.ClientSession(loop=self._loop)
|
|
77
|
+
self._owns_session = True
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
81
|
+
"""Exit async context manager."""
|
|
82
|
+
if self._session and self._owns_session:
|
|
83
|
+
await self._session.close()
|
|
84
|
+
self._session = None
|
|
85
|
+
|
|
86
|
+
async def _make_request(
|
|
87
|
+
self, method: str, endpoint: str, **kwargs
|
|
88
|
+
) -> tuple[dict[str, Any], dict[str, str]]:
|
|
89
|
+
"""Make an async HTTP request to the API.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Tuple of (response_data, response_headers)
|
|
93
|
+
"""
|
|
94
|
+
if not self._session:
|
|
95
|
+
raise RuntimeError("Client not initialized. Use async context manager.")
|
|
96
|
+
|
|
97
|
+
url = f"{self.BASE_URL}/{endpoint}"
|
|
98
|
+
kwargs.setdefault("timeout", self.timeout)
|
|
99
|
+
|
|
100
|
+
# Add Authorization header if we have a token (except for login)
|
|
101
|
+
if self._token and not endpoint.endswith("auth/login"):
|
|
102
|
+
headers = kwargs.get("headers", {})
|
|
103
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
104
|
+
kwargs["headers"] = headers
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
logger.debug(f"Making async {method} request to {url}: {kwargs}")
|
|
108
|
+
async with self._session.request(method, url, **kwargs) as response:
|
|
109
|
+
response.raise_for_status()
|
|
110
|
+
data = await response.json()
|
|
111
|
+
|
|
112
|
+
if data.get("statusCode") != 200:
|
|
113
|
+
raise SpaControlError(f"API error: {data.get('message')}")
|
|
114
|
+
|
|
115
|
+
return data, dict(response.headers)
|
|
116
|
+
|
|
117
|
+
except aiohttp.ClientResponseError as e:
|
|
118
|
+
if e.status == 401:
|
|
119
|
+
raise AuthenticationError("Authentication failed") from e
|
|
120
|
+
raise ConnectionError(f"HTTP error: {str(e)}") from e
|
|
121
|
+
|
|
122
|
+
except ClientError as e:
|
|
123
|
+
raise ConnectionError(f"Connection error: {str(e)}") from e
|
|
124
|
+
|
|
125
|
+
except json.JSONDecodeError as e:
|
|
126
|
+
raise ConnectionError(f"Invalid JSON response: {str(e)}") from e
|
|
127
|
+
|
|
128
|
+
async def authenticate(self) -> AuthResponse:
|
|
129
|
+
"""Authenticate with the Caldera API."""
|
|
130
|
+
logger.info("Authenticating with Caldera API")
|
|
131
|
+
try:
|
|
132
|
+
data, headers = await self._make_request(
|
|
133
|
+
"POST",
|
|
134
|
+
"auth/login",
|
|
135
|
+
json={
|
|
136
|
+
"emailAddress": self.email,
|
|
137
|
+
"password": self.password,
|
|
138
|
+
"deviceType": "IOS",
|
|
139
|
+
"osType": "17.4.1",
|
|
140
|
+
"mobileDeviceToken": "dummy_token:APA91bDummy0123456789",
|
|
141
|
+
"location": "",
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if not self._session:
|
|
146
|
+
raise RuntimeError("Session not initialized")
|
|
147
|
+
|
|
148
|
+
logger.debug(f"Authentication response: {json.dumps(data)}")
|
|
149
|
+
|
|
150
|
+
token = headers.get("Authorization", "")
|
|
151
|
+
|
|
152
|
+
if not token:
|
|
153
|
+
raise AuthenticationError("No authentication token received")
|
|
154
|
+
|
|
155
|
+
self._token = token # Store just the raw token
|
|
156
|
+
logger.debug(f"Received authentication token: {self._token}")
|
|
157
|
+
logger.debug("Authentication successful")
|
|
158
|
+
return AuthResponse(**data)
|
|
159
|
+
|
|
160
|
+
except aiohttp.ClientResponseError as e:
|
|
161
|
+
logger.error(f"Authentication failed: {str(e)}")
|
|
162
|
+
raise AuthenticationError("Authentication failed") from e
|
|
163
|
+
except Exception as e:
|
|
164
|
+
logger.error(f"Authentication failed: {str(e)}")
|
|
165
|
+
raise
|
|
166
|
+
|
|
167
|
+
async def _ensure_auth(self) -> None:
|
|
168
|
+
"""Ensure we have a valid authentication token."""
|
|
169
|
+
if not self._token:
|
|
170
|
+
await self.authenticate()
|
|
171
|
+
|
|
172
|
+
async def _ensure_spa_info(self) -> None:
|
|
173
|
+
"""Ensure we have the spa ID and HNA number."""
|
|
174
|
+
if not self._hna_number or not self._spa_id:
|
|
175
|
+
status = await self.get_spa_status()
|
|
176
|
+
self._hna_number = status.hnaNumber
|
|
177
|
+
self._spa_id = status.spaId
|
|
178
|
+
|
|
179
|
+
def _parse_json_field(self, obj: dict, field_name: str) -> None:
|
|
180
|
+
"""Parse a JSON string field and replace it with the parsed object.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
obj: Dictionary containing the field to parse
|
|
184
|
+
field_name: Name of the field containing JSON string
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
SpaControlError: If JSON parsing fails
|
|
188
|
+
"""
|
|
189
|
+
if field_name in obj:
|
|
190
|
+
try:
|
|
191
|
+
json_str = obj[field_name]
|
|
192
|
+
parsed_data = json.loads(json_str)
|
|
193
|
+
obj[field_name] = parsed_data
|
|
194
|
+
except json.JSONDecodeError as e:
|
|
195
|
+
logger.error(f"Failed to parse {field_name}: {e}")
|
|
196
|
+
raise SpaControlError(f"Invalid {field_name} format") from e
|
|
197
|
+
|
|
198
|
+
async def get_spa_status(self) -> SpaResponseDato:
|
|
199
|
+
"""Get the current status of the spa.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
SpaResponseDato containing current spa state
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
AuthenticationError: If authentication fails
|
|
206
|
+
ConnectionError: If connection fails
|
|
207
|
+
SpaControlError: If the API returns an error
|
|
208
|
+
"""
|
|
209
|
+
await self._ensure_auth()
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
data, _ = await self._make_request("POST", "spa/my-spas", json={})
|
|
213
|
+
# logger.debug(f"Spa status response: {json.dumps(data)}")
|
|
214
|
+
|
|
215
|
+
# Pre-process the response to parse nested JSON
|
|
216
|
+
if not isinstance(data, dict):
|
|
217
|
+
raise SpaControlError("Invalid response format")
|
|
218
|
+
|
|
219
|
+
response_data = data.get("data", {}).get("responseDto", [])
|
|
220
|
+
if not response_data:
|
|
221
|
+
raise SpaControlError("No spa data in response")
|
|
222
|
+
|
|
223
|
+
# Parse nested JSON fields for each spa in the response
|
|
224
|
+
for spa in response_data:
|
|
225
|
+
# Parse thingWorxData in spaSettings
|
|
226
|
+
if "spaSettings" in spa:
|
|
227
|
+
self._parse_json_field(spa["spaSettings"], "thingWorxData")
|
|
228
|
+
|
|
229
|
+
# Parse fields in isConnectedData
|
|
230
|
+
if "isConnectedData" in spa:
|
|
231
|
+
connected_data = spa["isConnectedData"]
|
|
232
|
+
self._parse_json_field(connected_data, "liveSettings")
|
|
233
|
+
self._parse_json_field(connected_data, "isDeviceConnected")
|
|
234
|
+
|
|
235
|
+
# Now validate with pydantic
|
|
236
|
+
response = SpaStatusResponse(**data)
|
|
237
|
+
return response.data.responseDto[0]
|
|
238
|
+
|
|
239
|
+
except aiohttp.ClientResponseError as e:
|
|
240
|
+
logger.error(f"Failed to get spa status: {str(e)}")
|
|
241
|
+
if e.status == 401:
|
|
242
|
+
raise AuthenticationError("Authentication failed") from e
|
|
243
|
+
raise ConnectionError(f"HTTP error: {str(e)}") from e
|
|
244
|
+
except (KeyError, IndexError) as e:
|
|
245
|
+
logger.error(f"Invalid response format: {str(e)}")
|
|
246
|
+
raise SpaControlError("Unexpected API response format") from e
|
|
247
|
+
except pydantic.ValidationError as e:
|
|
248
|
+
logger.error(f"Invalid response data: {str(e)}")
|
|
249
|
+
raise SpaControlError("Invalid spa status data received") from e
|
|
250
|
+
except Exception as e:
|
|
251
|
+
logger.error(f"Failed to get spa status: {str(e)}")
|
|
252
|
+
raise ConnectionError(f"Unexpected error: {str(e)}") from e
|
|
253
|
+
|
|
254
|
+
async def get_live_settings(self) -> LiveSettings:
|
|
255
|
+
"""Get current live settings from the spa.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
LiveSettings object containing current spa state
|
|
259
|
+
|
|
260
|
+
Raises:
|
|
261
|
+
AuthenticationError: If authentication fails
|
|
262
|
+
ConnectionError: If connection fails
|
|
263
|
+
SpaControlError: If the API returns an error
|
|
264
|
+
"""
|
|
265
|
+
await self._ensure_auth()
|
|
266
|
+
await self._ensure_spa_info()
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
data, _ = await self._make_request(
|
|
270
|
+
"GET", "setting/live-spa-settings", params={"hnaNo": self._hna_number}
|
|
271
|
+
)
|
|
272
|
+
logger.debug(f"Live settings response: {json.dumps(data)}")
|
|
273
|
+
|
|
274
|
+
# Pre-process the response to parse nested JSON
|
|
275
|
+
if not isinstance(data, dict):
|
|
276
|
+
raise SpaControlError("Invalid response format")
|
|
277
|
+
|
|
278
|
+
# Parse the nested JSON string in data field
|
|
279
|
+
self._parse_json_field(data, "data")
|
|
280
|
+
|
|
281
|
+
# Now validate with pydantic
|
|
282
|
+
response = LiveSettingsResponse(**data)
|
|
283
|
+
if not response.data.rows:
|
|
284
|
+
raise SpaControlError("No live settings data in response")
|
|
285
|
+
|
|
286
|
+
return response.data.rows[0]
|
|
287
|
+
|
|
288
|
+
except aiohttp.ClientResponseError as e:
|
|
289
|
+
logger.error(f"Failed to get live settings: {str(e)}")
|
|
290
|
+
if e.status == 401:
|
|
291
|
+
raise AuthenticationError("Authentication failed") from e
|
|
292
|
+
raise ConnectionError(f"HTTP error: {str(e)}") from e
|
|
293
|
+
except pydantic.ValidationError as e:
|
|
294
|
+
logger.error(f"Invalid response data: {str(e)}")
|
|
295
|
+
raise SpaControlError("Invalid spa settings data received") from e
|
|
296
|
+
except Exception as e:
|
|
297
|
+
logger.error(f"Failed to get live settings: {str(e)}")
|
|
298
|
+
raise ConnectionError(f"Unexpected error: {str(e)}") from e
|
|
299
|
+
|
|
300
|
+
async def set_temperature(self, temperature: float, unit: str = "F") -> bool:
|
|
301
|
+
"""Set the target temperature for the spa.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
temperature: Target temperature
|
|
305
|
+
unit: Temperature unit ('F' or 'C')
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
bool indicating success
|
|
309
|
+
|
|
310
|
+
Raises:
|
|
311
|
+
InvalidParameterError: If temperature is out of valid range
|
|
312
|
+
AuthenticationError: If authentication fails
|
|
313
|
+
ConnectionError: If connection fails
|
|
314
|
+
SpaControlError: If the API returns an error
|
|
315
|
+
"""
|
|
316
|
+
if unit.upper() == "F":
|
|
317
|
+
if not (80 <= temperature <= 104):
|
|
318
|
+
raise InvalidParameterError(
|
|
319
|
+
"Temperature must be between 80°F and 104°F"
|
|
320
|
+
)
|
|
321
|
+
else:
|
|
322
|
+
if not (26.5 <= temperature <= 40):
|
|
323
|
+
raise InvalidParameterError(
|
|
324
|
+
"Temperature must be between 26.5°C and 40°C"
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
await self._ensure_auth()
|
|
328
|
+
await self._ensure_spa_info()
|
|
329
|
+
|
|
330
|
+
# Convert to Fahrenheit if needed
|
|
331
|
+
if unit.upper() == "C":
|
|
332
|
+
temperature = (temperature * 9 / 5) + 32
|
|
333
|
+
|
|
334
|
+
# Hypothesize that 65535 is for 104 and 1 degree is 128
|
|
335
|
+
# (based on observing that 102 might be 65280)
|
|
336
|
+
temp_value = min(65535, 65536 - int((104 - temperature) * 128))
|
|
337
|
+
|
|
338
|
+
logger.debug(
|
|
339
|
+
f"Temperature encoding:\n"
|
|
340
|
+
f" Requested: {temperature}°F\n"
|
|
341
|
+
f" Current method: {temp_value} (0x{temp_value:04X})\n"
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
await self._make_request(
|
|
346
|
+
"POST",
|
|
347
|
+
"setting/send-my-spa-settings-to-thingWorx",
|
|
348
|
+
params={"hnaNo": self._hna_number, "spaTempStatus": 1},
|
|
349
|
+
json={"param": json.dumps({"usr_set_temperature": str(temp_value)})},
|
|
350
|
+
)
|
|
351
|
+
return True
|
|
352
|
+
except Exception as e:
|
|
353
|
+
logger.error(f"Failed to set temperature: {str(e)}")
|
|
354
|
+
raise
|
|
355
|
+
|
|
356
|
+
async def set_lights(self, state: bool) -> bool:
|
|
357
|
+
"""Turn spa lights on or off.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
state: True to turn lights on, False to turn off
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
bool indicating success
|
|
364
|
+
|
|
365
|
+
Raises:
|
|
366
|
+
AuthenticationError: If authentication fails
|
|
367
|
+
ConnectionError: If connection fails
|
|
368
|
+
SpaControlError: If the API returns an error
|
|
369
|
+
"""
|
|
370
|
+
await self._ensure_auth()
|
|
371
|
+
await self._ensure_spa_info()
|
|
372
|
+
|
|
373
|
+
light_value = "1041" if state else "1040"
|
|
374
|
+
logger.info(f"Setting lights {'on' if state else 'off'}")
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
await self._make_request(
|
|
378
|
+
"POST",
|
|
379
|
+
"setting/send-my-spa-settings-to-thingWorx",
|
|
380
|
+
params={"hnaNo": self._hna_number},
|
|
381
|
+
json={"param": json.dumps({"usr_set_mz_light": light_value})},
|
|
382
|
+
)
|
|
383
|
+
return True
|
|
384
|
+
except Exception as e:
|
|
385
|
+
logger.error(f"Failed to set lights: {str(e)}")
|
|
386
|
+
raise
|
|
387
|
+
|
|
388
|
+
async def set_pump(self, pump_number: int, speed: int) -> bool:
|
|
389
|
+
"""Control a jet pump.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
pump_number: Pump number (1-3)
|
|
393
|
+
speed: Pump speed (0=off, 1=low, 2=high)
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
bool indicating success
|
|
397
|
+
|
|
398
|
+
Raises:
|
|
399
|
+
InvalidParameterError: If pump number or speed is invalid
|
|
400
|
+
AuthenticationError: If authentication fails
|
|
401
|
+
ConnectionError: If connection fails
|
|
402
|
+
SpaControlError: If the API returns an error
|
|
403
|
+
"""
|
|
404
|
+
if not 1 <= pump_number <= 3:
|
|
405
|
+
raise InvalidParameterError("Pump number must be 1, 2, or 3")
|
|
406
|
+
if not 0 <= speed <= 2:
|
|
407
|
+
raise InvalidParameterError("Speed must be 0 (off), 1 (low), or 2 (high)")
|
|
408
|
+
|
|
409
|
+
await self._ensure_auth()
|
|
410
|
+
await self._ensure_spa_info()
|
|
411
|
+
|
|
412
|
+
param_name = f"usr_set_pump{pump_number}_speed"
|
|
413
|
+
logger.info(f"Setting pump {pump_number} to speed {speed}")
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
await self._make_request(
|
|
417
|
+
"POST",
|
|
418
|
+
"setting/send-my-spa-settings-to-thingWorx",
|
|
419
|
+
params={"hnaNo": self._hna_number},
|
|
420
|
+
json={"param": json.dumps({param_name: str(speed)})},
|
|
421
|
+
)
|
|
422
|
+
return True
|
|
423
|
+
except Exception as e:
|
|
424
|
+
logger.error(f"Failed to set pump: {str(e)}")
|
|
425
|
+
raise
|
|
426
|
+
|
|
427
|
+
async def set_temp_lock(self, locked: bool) -> bool:
|
|
428
|
+
"""Lock or unlock temperature controls.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
locked: True to lock, False to unlock
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
bool indicating success
|
|
435
|
+
|
|
436
|
+
Raises:
|
|
437
|
+
AuthenticationError: If authentication fails
|
|
438
|
+
ConnectionError: If connection fails
|
|
439
|
+
SpaControlError: If the API returns an error
|
|
440
|
+
"""
|
|
441
|
+
await self._ensure_auth()
|
|
442
|
+
await self._ensure_spa_info()
|
|
443
|
+
|
|
444
|
+
logger.info(f"Setting temperature lock to {'locked' if locked else 'unlocked'}")
|
|
445
|
+
|
|
446
|
+
try:
|
|
447
|
+
await self._make_request(
|
|
448
|
+
"POST",
|
|
449
|
+
"setting/send-my-spa-settings-to-thingWorx",
|
|
450
|
+
params={"hnaNo": self._hna_number},
|
|
451
|
+
json={
|
|
452
|
+
"param": json.dumps(
|
|
453
|
+
{"usr_set_temp_lock_state": "2" if locked else "1"}
|
|
454
|
+
)
|
|
455
|
+
},
|
|
456
|
+
)
|
|
457
|
+
return True
|
|
458
|
+
except Exception as e:
|
|
459
|
+
logger.error(f"Failed to set temperature lock: {str(e)}")
|
|
460
|
+
raise
|
|
461
|
+
|
|
462
|
+
async def set_spa_lock(self, locked: bool) -> bool:
|
|
463
|
+
"""Lock or unlock all spa controls.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
locked: True to lock, False to unlock
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
bool indicating success
|
|
470
|
+
|
|
471
|
+
Raises:
|
|
472
|
+
AuthenticationError: If authentication fails
|
|
473
|
+
ConnectionError: If connection fails
|
|
474
|
+
SpaControlError: If the API returns an error
|
|
475
|
+
"""
|
|
476
|
+
await self._ensure_auth()
|
|
477
|
+
await self._ensure_spa_info()
|
|
478
|
+
|
|
479
|
+
logger.info(f"Setting spa lock to {'locked' if locked else 'unlocked'}")
|
|
480
|
+
|
|
481
|
+
try:
|
|
482
|
+
await self._make_request(
|
|
483
|
+
"POST",
|
|
484
|
+
"setting/send-my-spa-settings-to-thingWorx",
|
|
485
|
+
params={"hnaNo": self._hna_number},
|
|
486
|
+
json={
|
|
487
|
+
"param": json.dumps(
|
|
488
|
+
{"usr_set_spa_lock_state": "2" if locked else "1"}
|
|
489
|
+
)
|
|
490
|
+
},
|
|
491
|
+
)
|
|
492
|
+
return True
|
|
493
|
+
except Exception as e:
|
|
494
|
+
logger.error(f"Failed to set spa lock: {str(e)}")
|
|
495
|
+
raise
|
pycaldera/exceptions.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Custom exceptions for the Caldera client."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CalderaError(Exception):
|
|
5
|
+
"""Base exception for all Caldera client errors."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthenticationError(CalderaError):
|
|
11
|
+
"""Raised when authentication fails."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConnectionError(CalderaError):
|
|
17
|
+
"""Raised when connection to the spa fails."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SpaControlError(CalderaError):
|
|
23
|
+
"""Raised when a control operation fails."""
|
|
24
|
+
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InvalidParameterError(CalderaError):
|
|
29
|
+
"""Raised when an invalid parameter is provided."""
|
|
30
|
+
|
|
31
|
+
pass
|
pycaldera/models.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Data models for Caldera Spa API."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthResponse(BaseModel):
|
|
9
|
+
"""Response from authentication endpoint."""
|
|
10
|
+
|
|
11
|
+
statusCode: int
|
|
12
|
+
message: str
|
|
13
|
+
data: dict
|
|
14
|
+
timeStamp: str
|
|
15
|
+
nTime: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LiveSettings(BaseModel):
|
|
19
|
+
"""Live settings from the spa ThingWorx system."""
|
|
20
|
+
|
|
21
|
+
# Temperature settings
|
|
22
|
+
ctrl_head_water_temperature: float
|
|
23
|
+
ctrl_head_set_temperature: float
|
|
24
|
+
usr_set_temperature: str
|
|
25
|
+
usr_set_temperature_ack: str
|
|
26
|
+
ctrl_head_water_temperature_ack: str
|
|
27
|
+
temp_diff: float
|
|
28
|
+
feature_configuration_degree_celcius: str
|
|
29
|
+
|
|
30
|
+
# Pump settings
|
|
31
|
+
usr_set_pump1_speed: str
|
|
32
|
+
usr_set_pump2_speed: str
|
|
33
|
+
usr_set_pump3_speed: str
|
|
34
|
+
usr_set_blower: str
|
|
35
|
+
usr_set_heat_pump: str
|
|
36
|
+
|
|
37
|
+
# Light settings
|
|
38
|
+
usr_set_light_state: str
|
|
39
|
+
usr_set_mz_light: str
|
|
40
|
+
usr_set_mz_ack: str
|
|
41
|
+
|
|
42
|
+
# Lock states
|
|
43
|
+
usr_set_temp_lock_state: str
|
|
44
|
+
usr_set_spa_lock_state: str
|
|
45
|
+
usr_set_clean_lock_state: str
|
|
46
|
+
|
|
47
|
+
# Filter and cleaning
|
|
48
|
+
filter_time_1: str
|
|
49
|
+
filter_time_2: str
|
|
50
|
+
usr_set_clean_cycle: str
|
|
51
|
+
usr_set_stm_state: str
|
|
52
|
+
|
|
53
|
+
# Audio settings
|
|
54
|
+
audio_power: str
|
|
55
|
+
audio_source_selection: str
|
|
56
|
+
usr_set_audio_data: str
|
|
57
|
+
usr_set_audio_ack: str
|
|
58
|
+
|
|
59
|
+
# System status
|
|
60
|
+
mz_system_status: str
|
|
61
|
+
hawk_status_econ: str
|
|
62
|
+
g3_level2_errors: str
|
|
63
|
+
g3_clrmtr_test_data: str
|
|
64
|
+
lls_power_and_ready_ace_err: str
|
|
65
|
+
usr_set_system_reset: str
|
|
66
|
+
|
|
67
|
+
# Usage tracking
|
|
68
|
+
spa_usage: str
|
|
69
|
+
usr_spa_usage: str
|
|
70
|
+
salline_test: str
|
|
71
|
+
|
|
72
|
+
# Menu entries
|
|
73
|
+
usr_set_tanas_menu_entry: str
|
|
74
|
+
usr_set_tanas_menu_entry_ack: str
|
|
75
|
+
usr_set_tanas_menu_entry_test: str
|
|
76
|
+
usr_set_tanas_menu_entry_boost: str
|
|
77
|
+
|
|
78
|
+
# Metadata
|
|
79
|
+
name: str
|
|
80
|
+
description: str
|
|
81
|
+
thingTemplate: str
|
|
82
|
+
tags: list[dict[str, str]]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class LiveSettingsFieldDefinition(BaseModel):
|
|
86
|
+
"""Definition of a field in live settings."""
|
|
87
|
+
|
|
88
|
+
name: str
|
|
89
|
+
description: str = ""
|
|
90
|
+
baseType: str
|
|
91
|
+
ordinal: int
|
|
92
|
+
aspects: dict[str, Any]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class LiveSettingsDataShape(BaseModel):
|
|
96
|
+
"""Shape of the live settings data."""
|
|
97
|
+
|
|
98
|
+
fieldDefinitions: dict[str, LiveSettingsFieldDefinition]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class LiveSettingsData(BaseModel):
|
|
102
|
+
"""Parsed live settings data structure."""
|
|
103
|
+
|
|
104
|
+
dataShape: LiveSettingsDataShape
|
|
105
|
+
rows: list[LiveSettings]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class LiveSettingsResponse(BaseModel):
|
|
109
|
+
"""Response from the live settings endpoint."""
|
|
110
|
+
|
|
111
|
+
statusCode: int
|
|
112
|
+
message: str
|
|
113
|
+
data: LiveSettingsData
|
|
114
|
+
oldUserData: Optional[Any] = None
|
|
115
|
+
timeStamp: str
|
|
116
|
+
nTime: str
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class SpaDetails(BaseModel):
|
|
120
|
+
"""Details about a specific spa model."""
|
|
121
|
+
|
|
122
|
+
Brand: str
|
|
123
|
+
Series: str
|
|
124
|
+
Model: str
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class LightSettings(BaseModel):
|
|
128
|
+
"""Light configuration for the spa."""
|
|
129
|
+
|
|
130
|
+
Lights: bool
|
|
131
|
+
Bartop: bool
|
|
132
|
+
Dimming: str
|
|
133
|
+
Underwater_Main_Light: bool
|
|
134
|
+
Water_Feature: bool
|
|
135
|
+
Lighting_Type: str
|
|
136
|
+
Exterior_Light: bool
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class JetPumps(BaseModel):
|
|
140
|
+
"""Configuration of jet pumps."""
|
|
141
|
+
|
|
142
|
+
Jet_Pump_1: str
|
|
143
|
+
Jet_Pump_2: str
|
|
144
|
+
Jet_Pump_3: str
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class OptionalFeatures(BaseModel):
|
|
148
|
+
"""Optional spa features."""
|
|
149
|
+
|
|
150
|
+
Audio: bool
|
|
151
|
+
CoolZoneTM: bool
|
|
152
|
+
FreshWater_Salt_SystemTM: bool = Field(alias="FreshWater Salt SystemTM")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class SpaConfiguration(BaseModel):
|
|
156
|
+
"""Complete spa configuration."""
|
|
157
|
+
|
|
158
|
+
Control_Box: str = Field(alias="Control Box")
|
|
159
|
+
Circulation_Pump: str = Field(alias="Circulation Pump")
|
|
160
|
+
SPA_Details: SpaDetails
|
|
161
|
+
JET_PUMPS: JetPumps = Field(alias="JET PUMPS")
|
|
162
|
+
Summer_Timer: str = Field(alias="Summer_Timer")
|
|
163
|
+
Lights: LightSettings
|
|
164
|
+
OPTIONAL_FEATURES: OptionalFeatures = Field(alias="OPTIONAL FEATURES")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class SpaSettings(BaseModel):
|
|
168
|
+
"""Settings configuration for the spa."""
|
|
169
|
+
|
|
170
|
+
id: int
|
|
171
|
+
thingWorxData: SpaConfiguration # Changed from str to SpaConfiguration
|
|
172
|
+
tempLock: bool
|
|
173
|
+
spaLock: bool
|
|
174
|
+
filterStatus: Optional[str]
|
|
175
|
+
cleanUpCycle: bool
|
|
176
|
+
summerTimer: bool
|
|
177
|
+
units: Optional[str]
|
|
178
|
+
spaEmailNotification: bool
|
|
179
|
+
promotionEmailNotification: bool
|
|
180
|
+
spaPushNotification: bool
|
|
181
|
+
createdAt: str
|
|
182
|
+
updatedAt: str
|
|
183
|
+
userTempratureUnit: bool
|
|
184
|
+
promotionPushNotification: bool
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class DeviceConnectionRow(BaseModel):
|
|
188
|
+
"""Single row of device connection data."""
|
|
189
|
+
|
|
190
|
+
result: bool
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class ThingWorxResponse(BaseModel):
|
|
194
|
+
"""Generic ThingWorx response structure."""
|
|
195
|
+
|
|
196
|
+
dataShape: dict # Keep as dict since it's metadata we don't need
|
|
197
|
+
rows: list
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class ThingWorxLiveSettings(ThingWorxResponse):
|
|
201
|
+
"""Live settings from ThingWorx system."""
|
|
202
|
+
|
|
203
|
+
rows: list[LiveSettings] # Override rows type for live settings
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class ThingWorxDeviceConnection(ThingWorxResponse):
|
|
207
|
+
"""Device connection status from ThingWorx system."""
|
|
208
|
+
|
|
209
|
+
rows: list[DeviceConnectionRow] # Override rows type for device connection
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class IsConnectedData(BaseModel):
|
|
213
|
+
"""Connection status data for the spa."""
|
|
214
|
+
|
|
215
|
+
liveSettings: ThingWorxLiveSettings
|
|
216
|
+
isDeviceConnected: ThingWorxDeviceConnection
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class SpaResponseDato(BaseModel):
|
|
220
|
+
"""Individual spa response data."""
|
|
221
|
+
|
|
222
|
+
spaId: int
|
|
223
|
+
spaName: str
|
|
224
|
+
spaSerialNumber: str
|
|
225
|
+
hnaNumber: str
|
|
226
|
+
snaNumber: str
|
|
227
|
+
output: Optional[str]
|
|
228
|
+
address: Optional[str]
|
|
229
|
+
state: Optional[str]
|
|
230
|
+
country: Optional[str]
|
|
231
|
+
postalCode: Optional[str]
|
|
232
|
+
status: str
|
|
233
|
+
spaSettings: SpaSettings
|
|
234
|
+
spaTempStatus: int
|
|
235
|
+
installationDate: str
|
|
236
|
+
spaOwnerStatus: str
|
|
237
|
+
invitationMailStatus: str
|
|
238
|
+
isConnectedData: IsConnectedData
|
|
239
|
+
userId: int
|
|
240
|
+
firstName: str
|
|
241
|
+
lastName: str
|
|
242
|
+
emailAddress: str
|
|
243
|
+
userTempratureUnit: bool
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class ResponseData(BaseModel):
|
|
247
|
+
"""Data field for spa status response."""
|
|
248
|
+
|
|
249
|
+
responseDto: list[SpaResponseDato]
|
|
250
|
+
unReadNotificationCount: int
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class SpaStatusResponse(BaseModel):
|
|
254
|
+
"""Complete response from spa status endpoint."""
|
|
255
|
+
|
|
256
|
+
statusCode: int
|
|
257
|
+
message: str
|
|
258
|
+
data: ResponseData
|
|
259
|
+
oldUserData: list
|
|
260
|
+
timeStamp: str
|
|
261
|
+
nTime: str
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Mark Watson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: pycaldera
|
|
3
|
+
Version: 0.1.dev0
|
|
4
|
+
Summary: Unofficial Python client for Caldera Spa API
|
|
5
|
+
Home-page: https://github.com/mwatson2/pycaldera
|
|
6
|
+
Author: Mark Watson
|
|
7
|
+
Author-email: markwatson@cantab.net
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Natural Language :: English
|
|
10
|
+
Classifier: Programming Language :: Python
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: aiohttp>=3.8.0
|
|
21
|
+
Requires-Dist: pydantic>=2.0.0
|
|
22
|
+
Requires-Dist: black>=23.0.0
|
|
23
|
+
Requires-Dist: isort>=5.12.0
|
|
24
|
+
Requires-Dist: mypy>=1.0.0
|
|
25
|
+
Requires-Dist: pylint>=2.17.0
|
|
26
|
+
Requires-Dist: pytest>=7.0.0
|
|
27
|
+
Requires-Dist: pytest-aiohttp>=1.0.0
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.21.0
|
|
29
|
+
Requires-Dist: pytest-cov>=4.0.0
|
|
30
|
+
Provides-Extra: test
|
|
31
|
+
Requires-Dist: pytest; extra == "test"
|
|
32
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
33
|
+
Requires-Dist: pytest-flake8; extra == "test"
|
|
34
|
+
Provides-Extra: docs
|
|
35
|
+
Requires-Dist: myst-parser; extra == "docs"
|
|
36
|
+
Requires-Dist: pypandoc>=1.6.3; extra == "docs"
|
|
37
|
+
Requires-Dist: readthedocs-sphinx-search; python_version >= "3.6" and extra == "docs"
|
|
38
|
+
Requires-Dist: sphinx<6,>=3.5.4; extra == "docs"
|
|
39
|
+
Requires-Dist: sphinx-autobuild; extra == "docs"
|
|
40
|
+
Requires-Dist: sphinx_book_theme; extra == "docs"
|
|
41
|
+
Requires-Dist: watchdog<1.0.0; python_version < "3.6" and extra == "docs"
|
|
42
|
+
Provides-Extra: dev
|
|
43
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
44
|
+
Requires-Dist: isort>=5.12.0; extra == "dev"
|
|
45
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
46
|
+
Requires-Dist: pylint>=2.17.0; extra == "dev"
|
|
47
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
48
|
+
Requires-Dist: pytest-aiohttp>=1.0.0; extra == "dev"
|
|
49
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
50
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
51
|
+
Provides-Extra: all
|
|
52
|
+
Requires-Dist: black>=23.0.0; extra == "all"
|
|
53
|
+
Requires-Dist: isort>=5.12.0; extra == "all"
|
|
54
|
+
Requires-Dist: mypy>=1.0.0; extra == "all"
|
|
55
|
+
Requires-Dist: myst-parser; extra == "all"
|
|
56
|
+
Requires-Dist: pylint>=2.17.0; extra == "all"
|
|
57
|
+
Requires-Dist: pypandoc>=1.6.3; extra == "all"
|
|
58
|
+
Requires-Dist: pytest; extra == "all"
|
|
59
|
+
Requires-Dist: pytest-aiohttp>=1.0.0; extra == "all"
|
|
60
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "all"
|
|
61
|
+
Requires-Dist: pytest-cov; extra == "all"
|
|
62
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "all"
|
|
63
|
+
Requires-Dist: pytest-flake8; extra == "all"
|
|
64
|
+
Requires-Dist: pytest>=7.0.0; extra == "all"
|
|
65
|
+
Requires-Dist: readthedocs-sphinx-search; python_version >= "3.6" and extra == "all"
|
|
66
|
+
Requires-Dist: sphinx-autobuild; extra == "all"
|
|
67
|
+
Requires-Dist: sphinx<6,>=3.5.4; extra == "all"
|
|
68
|
+
Requires-Dist: sphinx_book_theme; extra == "all"
|
|
69
|
+
Requires-Dist: watchdog<1.0.0; python_version < "3.6" and extra == "all"
|
|
70
|
+
Dynamic: author
|
|
71
|
+
Dynamic: author-email
|
|
72
|
+
Dynamic: classifier
|
|
73
|
+
Dynamic: description
|
|
74
|
+
Dynamic: description-content-type
|
|
75
|
+
Dynamic: home-page
|
|
76
|
+
Dynamic: license
|
|
77
|
+
Dynamic: provides-extra
|
|
78
|
+
Dynamic: requires-dist
|
|
79
|
+
Dynamic: requires-python
|
|
80
|
+
Dynamic: summary
|
|
81
|
+
|
|
82
|
+
# pycaldera
|
|
83
|
+
|
|
84
|
+
Python client library for controlling Caldera spas via their cloud API.
|
|
85
|
+
|
|
86
|
+
## Installation
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pip install pycaldera
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Usage
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
import asyncio
|
|
96
|
+
from pycaldera import AsyncCalderaClient, PUMP_OFF, PUMP_LOW, PUMP_HIGH
|
|
97
|
+
|
|
98
|
+
async def main():
|
|
99
|
+
async with AsyncCalderaClient("email@example.com", "password") as spa:
|
|
100
|
+
# Get current spa status
|
|
101
|
+
status = await spa.get_spa_status()
|
|
102
|
+
print(f"Current temperature: {status.ctrl_head_water_temperature}°F")
|
|
103
|
+
|
|
104
|
+
# Get detailed live settings
|
|
105
|
+
settings = await spa.get_live_settings()
|
|
106
|
+
print(f"Target temperature: {settings.ctrl_head_set_temperature}°F")
|
|
107
|
+
|
|
108
|
+
# Control the spa
|
|
109
|
+
await spa.set_temperature(102) # Set temperature to 102°F
|
|
110
|
+
await spa.set_pump(1, PUMP_HIGH) # Set pump 1 to high speed
|
|
111
|
+
await spa.set_lights(True) # Turn on the lights
|
|
112
|
+
|
|
113
|
+
asyncio.run(main())
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## API Reference
|
|
117
|
+
|
|
118
|
+
### AsyncCalderaClient
|
|
119
|
+
|
|
120
|
+
Main client class for interacting with the spa.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
client = AsyncCalderaClient(
|
|
124
|
+
email="email@example.com",
|
|
125
|
+
password="password",
|
|
126
|
+
timeout=10.0, # Optional: request timeout in seconds
|
|
127
|
+
debug=False, # Optional: enable debug logging
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Temperature Control
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
# Set temperature (80-104°F or 26.5-40°C)
|
|
135
|
+
await spa.set_temperature(102) # Fahrenheit
|
|
136
|
+
await spa.set_temperature(39, "C") # Celsius
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Pump Control
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
# Set pump speed
|
|
143
|
+
await spa.set_pump(1, PUMP_HIGH) # Set pump 1 to high speed
|
|
144
|
+
await spa.set_pump(2, PUMP_LOW) # Set pump 2 to low speed
|
|
145
|
+
await spa.set_pump(3, PUMP_OFF) # Turn off pump 3
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Light Control
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
await spa.set_lights(True) # Turn lights on
|
|
152
|
+
await spa.set_lights(False) # Turn lights off
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Status & Settings
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
# Get basic spa status
|
|
159
|
+
status = await spa.get_spa_status()
|
|
160
|
+
print(f"Spa name: {status.spaName}")
|
|
161
|
+
print(f"Current temp: {status.ctrl_head_water_temperature}°F")
|
|
162
|
+
print(f"Online: {status.status == 'ONLINE'}")
|
|
163
|
+
|
|
164
|
+
# Get detailed live settings
|
|
165
|
+
settings = await spa.get_live_settings()
|
|
166
|
+
print(f"Target temp: {settings.ctrl_head_set_temperature}°F")
|
|
167
|
+
print(f"Pump 1 speed: {settings.usr_set_pump1_speed}")
|
|
168
|
+
print(f"Lights on: {settings.usr_set_light_state}")
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Development
|
|
172
|
+
|
|
173
|
+
1. Clone the repository
|
|
174
|
+
2. Create a virtual environment:
|
|
175
|
+
```bash
|
|
176
|
+
python -m venv venv
|
|
177
|
+
source venv/bin/activate # or `venv\Scripts\activate` on Windows
|
|
178
|
+
```
|
|
179
|
+
3. Install development dependencies:
|
|
180
|
+
```bash
|
|
181
|
+
pip install -r requirements-dev.txt
|
|
182
|
+
```
|
|
183
|
+
4. Install pre-commit hooks:
|
|
184
|
+
```bash
|
|
185
|
+
pre-commit install
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The pre-commit hooks will run automatically on git commit, checking:
|
|
189
|
+
- Code formatting (Black)
|
|
190
|
+
- Import sorting (isort)
|
|
191
|
+
- Type checking (mypy)
|
|
192
|
+
- Linting (pylint, ruff)
|
|
193
|
+
- YAML/TOML syntax
|
|
194
|
+
- Trailing whitespace and file endings
|
|
195
|
+
|
|
196
|
+
## License
|
|
197
|
+
|
|
198
|
+
MIT License - see LICENSE file for details.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pycaldera/__init__.py,sha256=SedSnpd6wzkEFL0-bBriisov9hXmn-w8fElAaa0EUzk,475
|
|
2
|
+
pycaldera/__meta__.py,sha256=4SeJRcZ0dx95LkerGYzb5tMxDhByJ8xLuSUC_HRAwx0,581
|
|
3
|
+
pycaldera/async_client.py,sha256=HypE0XmshrBq3A26zTYqakCKN0PNqhn-dhxXdBl8mJ0,17515
|
|
4
|
+
pycaldera/exceptions.py,sha256=DYXRGoCL30fWyD1zw9pBj2Vhir1e3mUKKJwXqbPAjWY,553
|
|
5
|
+
pycaldera/models.py,sha256=Tu-zMUXyUY6-flFHlpH_2zQBf1NuX3b_-lKLaHJJSnM,5929
|
|
6
|
+
pycaldera-0.1.dev0.dist-info/LICENSE,sha256=oYq3ICPDw6q1C74Hei_Y1Q5C0l3-OmLcNp5GWRj8kJM,1068
|
|
7
|
+
pycaldera-0.1.dev0.dist-info/METADATA,sha256=ZWk1tmuyPd5U_4_xDfTqSI4AXB7nJ4V2jABwYuOAGjc,6016
|
|
8
|
+
pycaldera-0.1.dev0.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
|
9
|
+
pycaldera-0.1.dev0.dist-info/top_level.txt,sha256=35Jd7XNrb8Mk0PyhbVarv3GtWx4pegc8MPx5yznIcYI,10
|
|
10
|
+
pycaldera-0.1.dev0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pycaldera
|