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 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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.8.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ pycaldera