python-hotspring 1.2.0__py3-none-any.whl → 2.0.0__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.
hotspring/__init__.py CHANGED
@@ -6,6 +6,7 @@ from .const import (
6
6
  JetSpeed,
7
7
  LightColor,
8
8
  LightWheelMode,
9
+ SpaBrand,
9
10
  SpaFailureState,
10
11
  TemperatureUnit,
11
12
  )
@@ -58,6 +59,7 @@ __all__ = [
58
59
  "LightZone",
59
60
  "LogoLight",
60
61
  "Spa",
62
+ "SpaBrand",
61
63
  "SpaFailureState",
62
64
  "SpaInfo",
63
65
  "SpaLock",
hotspring/const.py CHANGED
@@ -82,8 +82,7 @@ class LightColor(Enum):
82
82
  """
83
83
 
84
84
  UNKNOWN = "unknown"
85
- OFF = "WHEEL_OFF"
86
- ON = "WHEEL_ON"
85
+ CUSTOM = "CUSTOM"
87
86
  RED = "RED"
88
87
  BLUE = "BLUE"
89
88
  GREEN = "GREEN"
@@ -96,7 +95,7 @@ class LightColor(Enum):
96
95
  def build(cls, value: str | None) -> LightColor:
97
96
  """Parse a raw API string into a LightColor.
98
97
 
99
- Case-insensitive matching (real API returns e.g. "BLUE").
98
+ Case-insensitive matching (real API returns e.g. "BLUE", "custom").
100
99
 
101
100
  Args:
102
101
  ----
@@ -235,3 +234,177 @@ class SpaFailureState(Enum):
235
234
 
236
235
 
237
236
  _FAILURE_STATE_MAP: dict[str, SpaFailureState] = {s.value: s for s in SpaFailureState}
237
+
238
+
239
+ class SpaBrand(Enum):
240
+ """Brand of the spa (e.g. HotSpring, Caldera)."""
241
+
242
+ UNKNOWN = "Unknown"
243
+ HOTSPRING = "HotSpring"
244
+ CALDERA = "Caldera"
245
+
246
+ @classmethod
247
+ def build(cls, value: str | int | None) -> SpaBrand:
248
+ """Parse a raw API string or integer into a SpaBrand.
249
+
250
+ Args:
251
+ ----
252
+ value: The raw brand ID from the API, or None.
253
+
254
+ Returns:
255
+ -------
256
+ The matching SpaBrand enum.
257
+
258
+ """
259
+ if value is None:
260
+ return cls.UNKNOWN
261
+ try:
262
+ val_int = int(str(value).strip())
263
+ except ValueError:
264
+ return cls.UNKNOWN
265
+
266
+ if val_int == 0:
267
+ return cls.HOTSPRING
268
+ if val_int == 1:
269
+ return cls.CALDERA
270
+ return cls.UNKNOWN
271
+
272
+
273
+ SPA_COLLECTION_MAP: dict[tuple[int, int], str] = {
274
+ # HotSpring (Brand 0)
275
+ (0, 0): "HighLife",
276
+ (0, 1): "Limelight",
277
+ (0, 2): "Hot Spot",
278
+ # Caldera (Brand 1)
279
+ (1, 1): "Utopia",
280
+ (1, 3): "Paradise",
281
+ (1, 4): "Vacanza",
282
+ }
283
+
284
+ SPA_MODEL_MAP: dict[tuple[int, int, int], str] = {
285
+ # Brand 0: HotSpring | Collection 0: HighLife
286
+ (0, 0, 0): "HotSpring HighLife",
287
+ (0, 0, 1): "HighLife Jetsetter",
288
+ (0, 0, 2): "HighLife Jetsetter Canada",
289
+ (0, 0, 3): "HighLife Jetsetter LX",
290
+ (0, 0, 4): "HighLife Prodigy",
291
+ (0, 0, 5): "HighLife Sovereign",
292
+ (0, 0, 6): "HighLife Aria",
293
+ (0, 0, 7): "HighLife Envoy",
294
+ (0, 0, 8): "HighLife Vanguard",
295
+ (0, 0, 9): "HighLife Grandee",
296
+ (0, 0, 10): "HighLife Jetsetter International",
297
+ (0, 0, 11): "HighLife Jetsetter LX International",
298
+ (0, 0, 12): "HighLife Prodigy International",
299
+ (0, 0, 13): "HighLife Sovereign International",
300
+ (0, 0, 14): "HighLife Aria International",
301
+ (0, 0, 15): "HighLife Envoy International",
302
+ (0, 0, 16): "HighLife Vanguard International",
303
+ (0, 0, 17): "HighLife Grandee International",
304
+ # Brand 0: HotSpring | Collection 1: Limelight
305
+ (0, 1, 0): "HotSpring Limelight",
306
+ (0, 1, 1): "Limelight Beam",
307
+ (0, 1, 2): "Limelight Beam II",
308
+ (0, 1, 3): "Limelight Beam International",
309
+ (0, 1, 4): "Limelight Beam Canada",
310
+ (0, 1, 5): "Limelight Strobe",
311
+ (0, 1, 6): "Limelight Strobe International",
312
+ (0, 1, 7): "Limelight Flair",
313
+ (0, 1, 8): "Limelight Flair International",
314
+ (0, 1, 9): "Limelight Flash",
315
+ (0, 1, 10): "Limelight Flash International",
316
+ (0, 1, 11): "Limelight Pulse",
317
+ (0, 1, 12): "Limelight Pulse International",
318
+ (0, 1, 13): "Limelight Prism",
319
+ (0, 1, 14): "Limelight Prism International",
320
+ # Brand 0: HotSpring | Collection 2: Hot Spot
321
+ (0, 2, 0): "Hot Spot Sx",
322
+ (0, 2, 1): "Hot Spot Tx",
323
+ (0, 2, 2): "Hot Spot Pace",
324
+ (0, 2, 3): "Hot Spot Stride",
325
+ (0, 2, 4): "Hot Spot Relay",
326
+ (0, 2, 5): "Hot Spot Rhythm",
327
+ (0, 2, 6): "Hot Spot Sx",
328
+ (0, 2, 7): "Hot Spot Tx",
329
+ (0, 2, 8): "Hot Spot Propel",
330
+ (0, 2, 9): "Hot Spot Stride",
331
+ (0, 2, 10): "Hot Spot Relay",
332
+ (0, 2, 11): "Hot Spot Rhythm",
333
+ # Brand 1: Caldera | Collection 1: Utopia
334
+ (1, 1, 0): "Caldera Utopia",
335
+ (1, 1, 1): "Utopia Ravello International",
336
+ (1, 1, 2): "Utopia Niagara International",
337
+ (1, 1, 3): "Utopia Tahitian International",
338
+ (1, 1, 4): "Utopia Florence International",
339
+ (1, 1, 5): "Utopia Geneva International",
340
+ (1, 1, 6): "Utopia Cantabria International",
341
+ (1, 1, 7): "Utopia Ravello",
342
+ (1, 1, 8): "Utopia Niagara",
343
+ (1, 1, 9): "Utopia Tahitian",
344
+ (1, 1, 10): "Utopia Florence",
345
+ (1, 1, 11): "Utopia Geneva",
346
+ (1, 1, 12): "Utopia Cantabria",
347
+ # Brand 1: Caldera | Collection 3: Paradise
348
+ (1, 3, 0): "Caldera Paradise",
349
+ (1, 3, 1): "Paradise Kauai",
350
+ (1, 3, 2): "Paradise Kauai International",
351
+ (1, 3, 3): "Paradise Martinique",
352
+ (1, 3, 4): "Paradise Martinique International",
353
+ (1, 3, 5): "Paradise Makena",
354
+ (1, 3, 6): "Paradise Makena International",
355
+ (1, 3, 7): "Paradise Salina",
356
+ (1, 3, 8): "Paradise Salina International",
357
+ (1, 3, 9): "Paradise Reunion",
358
+ (1, 3, 10): "Paradise Reunion International",
359
+ (1, 3, 11): "Paradise Seychelles",
360
+ (1, 3, 12): "Paradise Seychelles International",
361
+ # Brand 1: Caldera | Collection 4: Vacanza
362
+ (1, 4, 0): "Vacanza Aventine",
363
+ (1, 4, 1): "Vacanza Tarino",
364
+ (1, 4, 2): "Vacanza Capitolo",
365
+ (1, 4, 3): "Vacanza Celio",
366
+ (1, 4, 4): "Vacanza Platino",
367
+ (1, 4, 5): "Vacanza Vanto",
368
+ (1, 4, 6): "Vacanza Marino",
369
+ (1, 4, 7): "Vacanza Tarino_can",
370
+ (1, 4, 8): "Vacanza Aventine",
371
+ (1, 4, 9): "Vacanza Tarino",
372
+ (1, 4, 10): "Vacanza Capitolo",
373
+ (1, 4, 11): "Vacanza Celio",
374
+ (1, 4, 12): "Vacanza Marino",
375
+ (1, 4, 13): "Vacanza Platino",
376
+ (1, 4, 14): "Vacanza Vanto",
377
+ }
378
+
379
+
380
+ def resolve_spa_model(
381
+ brand_raw: str | int | None,
382
+ collection_raw: str | int | None,
383
+ model_raw: str | int | None,
384
+ ) -> tuple[SpaBrand, str, str]:
385
+ """Resolve raw API brand, collection, and model IDs to human-readable strings.
386
+
387
+ Args:
388
+ ----
389
+ brand_raw: Raw brand string or int from API (e.g. "0" or "1").
390
+ collection_raw: Raw collection string or int from API (e.g. "1").
391
+ model_raw: Raw model string or int from API (e.g. "4").
392
+
393
+ Returns:
394
+ -------
395
+ Tuple of (SpaBrand enum, collection name, model name).
396
+
397
+ """
398
+ brand = SpaBrand.build(brand_raw)
399
+
400
+ try:
401
+ brand_id = int(str(brand_raw)) if brand_raw is not None else -1
402
+ collection_id = int(str(collection_raw)) if collection_raw is not None else -1
403
+ model_id = int(str(model_raw)) if model_raw is not None else -1
404
+ except ValueError:
405
+ return (brand, "Unknown", "Unknown")
406
+
407
+ collection = SPA_COLLECTION_MAP.get((brand_id, collection_id), "Unknown")
408
+ model_name = SPA_MODEL_MAP.get((brand_id, collection_id, model_id), "Unknown")
409
+
410
+ return (brand, collection, model_name)
hotspring/hotspring.py CHANGED
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import contextlib
6
7
  import json
7
8
  from dataclasses import dataclass
8
9
  from typing import Self
@@ -11,6 +12,12 @@ import aiohttp
11
12
  import backoff
12
13
  from yarl import URL
13
14
 
15
+ from .const import (
16
+ HeatingMode,
17
+ JetSpeed,
18
+ LightColor,
19
+ LightWheelMode,
20
+ )
14
21
  from .exceptions import (
15
22
  HotSpringCommandError,
16
23
  HotSpringConnectionError,
@@ -23,6 +30,7 @@ from .models import (
23
30
  Diagnostics,
24
31
  FreshWaterIQ,
25
32
  Spa,
33
+ SpaInfo,
26
34
  )
27
35
 
28
36
 
@@ -47,6 +55,7 @@ class HotSpring:
47
55
  session: aiohttp.ClientSession | None = None
48
56
  request_timeout: float = 10.0
49
57
  _close_session: bool = False
58
+ _identity_loaded: bool = False
50
59
  spa: Spa | None = None
51
60
 
52
61
  @backoff.on_exception(
@@ -144,55 +153,102 @@ class HotSpring:
144
153
 
145
154
  return response_data
146
155
 
147
- async def update(self) -> Spa:
148
- """Get all spa information in a single polling cycle.
156
+ async def _safe_request(self, uri: str) -> dict[str, object] | None:
157
+ """Fetch an endpoint, returning None on error."""
158
+ with contextlib.suppress(HotSpringError):
159
+ return await self.request(uri)
160
+ return None
149
161
 
150
- This method fetches the main /status endpoint and combines it with
151
- identity information from /startup and /spamodel. Use this for
152
- the primary 15-second polling cycle.
162
+ async def update(self, *, refresh_identity: bool = False) -> Spa:
163
+ """Get all spa information.
153
164
 
154
- Returns
165
+ On the initial call (or when `refresh_identity=True`), this method fetches
166
+ the main /status endpoint concurrently with /startup, /spaConnectStatus,
167
+ and /spamodel.
168
+
169
+ On subsequent routine polling cycles, it only queries the fast /status
170
+ endpoint, avoiding redundant radio (LoRA) queries for static identity data.
171
+
172
+ Args:
173
+ ----
174
+ refresh_identity: Force re-fetching static identity from /startup
175
+ and /spamodel.
176
+
177
+ Returns:
155
178
  -------
156
179
  The updated Spa data object.
157
180
 
158
- Raises
181
+ Raises:
159
182
  ------
160
183
  HotSpringError: If no data is returned from the spa.
161
184
 
162
185
  """
163
- # Fetch main status
186
+ if not self._identity_loaded or refresh_identity:
187
+ status_res, startup_res, connect_res, model_res = await asyncio.gather(
188
+ self.request("/status"),
189
+ self._safe_request("/startup"),
190
+ self._safe_request("/spaConnectStatus"),
191
+ self._safe_request("/spamodel"),
192
+ )
193
+
194
+ if self.spa is None:
195
+ self.spa = Spa(status_res)
196
+ else:
197
+ self.spa.update_from_dict(status_res)
198
+
199
+ if startup_res:
200
+ self.spa.update_info(startup_res)
201
+
202
+ if model_res:
203
+ self.spa.update_info(model_res)
204
+
205
+ if connect_res:
206
+ self.spa.update_connection_status(connect_res)
207
+
208
+ self._identity_loaded = True
209
+ return self.spa
210
+
164
211
  status_data = await self.request("/status")
165
212
 
166
- if self.spa is None:
213
+ if self.spa is None: # Safety guard; spa is always set after cold sync
167
214
  self.spa = Spa(status_data)
168
215
  else:
169
216
  self.spa.update_from_dict(status_data)
170
217
 
171
- # Fetch identity/startup info
172
- identity_data: dict[str, object] = {}
173
- try:
174
- startup_data = await self.request("/startup")
175
- identity_data.update(startup_data)
176
- except HotSpringError:
177
- pass
218
+ return self.spa
178
219
 
179
- try:
180
- model_data = await self.request("/spamodel")
181
- identity_data.update(model_data)
182
- except HotSpringError:
183
- pass
220
+ async def update_identity(self) -> SpaInfo:
221
+ """Fetch and update static spa identity info (/startup and /spamodel).
222
+
223
+ Returns
224
+ -------
225
+ The updated SpaInfo data.
226
+
227
+ Raises
228
+ ------
229
+ HotSpringError: If the spa has not been initialized with update().
230
+
231
+ """
232
+ if self.spa is None:
233
+ msg = "Call update() before update_identity()"
234
+ raise HotSpringError(msg)
235
+
236
+ startup_res, model_res = await asyncio.gather(
237
+ self._safe_request("/startup"),
238
+ self._safe_request("/spamodel"),
239
+ )
240
+
241
+ identity_data: dict[str, object] = {}
242
+ if startup_res:
243
+ identity_data.update(startup_res)
244
+ if model_res:
245
+ identity_data.update(model_res)
184
246
 
185
247
  if identity_data:
186
248
  self.spa.update_info(identity_data)
187
249
 
188
- # Fetch connection status
189
- try:
190
- connect_data = await self.request("/spaConnectStatus")
191
- self.spa.update_connection_status(connect_data)
192
- except HotSpringError:
193
- pass # Non-critical
194
-
195
- return self.spa
250
+ self._identity_loaded = True
251
+ return self.spa.info
196
252
 
197
253
  async def update_water_care(self) -> FreshWaterIQ:
198
254
  """Update FreshWater IQ water quality data.
@@ -292,7 +348,11 @@ class HotSpring:
292
348
  raise HotSpringNotReadyError(msg)
293
349
 
294
350
  try:
295
- await self.request("/spaManager", method="POST", data=payload)
351
+ response_data = await self.request(
352
+ "/spaManager", method="POST", data=payload
353
+ )
354
+ if self.spa is not None:
355
+ self.spa.update_from_dict(response_data)
296
356
  except HotSpringError as exception:
297
357
  msg = f"Command failed: {payload}"
298
358
  raise HotSpringCommandError(msg) from exception
@@ -310,57 +370,53 @@ class HotSpring:
310
370
  {"heater": {"control": {"temperatureABS": str(temperature)}}}
311
371
  )
312
372
 
313
- async def set_heating_mode(self, mode: str) -> None:
373
+ async def set_heating_mode(self, mode: str | HeatingMode) -> None:
314
374
  """Set the heating mode.
315
375
 
316
376
  Args:
317
377
  ----
318
- mode: The heating mode value. Use HeatingMode enum values,
319
- e.g. ``HeatingMode.HEAT_WITH_BOOST.value``.
378
+ mode: The heating mode value. Use HeatingMode enum values or string,
379
+ e.g. ``HeatingMode.HEAT_WITH_BOOST.value`` or ``"heatWithBoost"``.
320
380
 
321
381
  """
322
- await self._send_command({"heater": {"control": {"heatingMode": mode}}})
382
+ mode_val = mode.value if isinstance(mode, HeatingMode) else str(mode)
383
+ await self._send_command({"heater": {"control": {"heatingMode": mode_val}}})
323
384
 
324
- async def set_jet(self, jet: int, speed: str) -> None:
385
+ async def set_jet(self, jet: int, speed: str | JetSpeed) -> None:
325
386
  """Set the speed of a jet pump.
326
387
 
327
388
  Args:
328
389
  ----
329
390
  jet: The jet number (1-based).
330
- speed: The speed value. Use JetSpeed enum values,
331
- e.g. ``JetSpeed.HIGH_SPEED.value``.
391
+ speed: The speed value. Use JetSpeed enum values or string,
392
+ e.g. ``JetSpeed.HIGH_SPEED.value`` or ``"highSpeed"``.
332
393
 
333
394
  """
334
- await self._send_command({"JET": {f"JET{jet}": {"control": speed}}})
395
+ speed_val = speed.value if isinstance(speed, JetSpeed) else str(speed)
396
+ await self._send_command({"JET": {f"JET{jet}": {"control": speed_val}}})
335
397
 
336
398
  async def set_light_color(
337
399
  self,
338
400
  zone: int,
339
- color: str,
340
- intensity: int = 5,
341
- light_wheel: str = "off",
401
+ color: str | LightColor,
342
402
  ) -> None:
343
403
  """Set the color of a light zone.
344
404
 
345
405
  Args:
346
406
  ----
347
407
  zone: The light zone number (1-based).
348
- color: The color value. Use LightColor enum values,
349
- e.g. ``LightColor.BLUE.value``.
350
- intensity: The brightness intensity (0-5). Defaults to 5.
351
- light_wheel: The light wheel mode. Use LightWheelMode enum values,
352
- e.g. ``LightWheelMode.OFF.value``. Defaults to "off".
408
+ color: The color value. Use LightColor enum values or string,
409
+ e.g. ``LightColor.BLUE.value`` or ``"BLUE"``.
353
410
 
354
411
  """
412
+ color_val = color.value if isinstance(color, LightColor) else str(color)
355
413
  await self._send_command(
356
414
  {
357
415
  "lights": {
358
416
  "control": {
359
417
  f"Zone{zone}": {
360
418
  "control": {
361
- "color": color.upper(),
362
- "IntensityAbs": intensity,
363
- "lightWheel": light_wheel,
419
+ "color": color_val.upper(),
364
420
  }
365
421
  }
366
422
  }
@@ -390,23 +446,60 @@ class HotSpring:
390
446
  }
391
447
  )
392
448
 
393
- async def set_light_brightness(self, zone: int, *, full: bool = True) -> None:
394
- """Set the brightness of a light zone.
449
+ async def set_light_brightness(self, zone: int, brightness: int) -> None:
450
+ """Set the brightness intensity of a light zone (0-5).
395
451
 
396
452
  Args:
397
453
  ----
398
454
  zone: The light zone number (1-based).
399
- full: True to set to full brightness ("fullon"), False to turn off ("off").
455
+ brightness: The brightness level (0 = off, 1 = lowest, 5 = maximum).
456
+
457
+ Raises:
458
+ ------
459
+ ValueError: If brightness is not an integer between 0 and 5.
400
460
 
401
461
  """
402
- intensity = "fullon" if full else "off"
462
+ if not 0 <= brightness <= 5:
463
+ msg = f"Brightness must be between 0 and 5, got {brightness}"
464
+ raise ValueError(msg)
465
+
403
466
  await self._send_command(
404
467
  {
405
468
  "lights": {
406
469
  "control": {
407
470
  f"Zone{zone}": {
408
471
  "control": {
409
- "Intensity": intensity,
472
+ "IntensityAbs": brightness,
473
+ }
474
+ }
475
+ }
476
+ }
477
+ }
478
+ )
479
+
480
+ async def set_light_wheel(
481
+ self,
482
+ zone: int,
483
+ mode: str | LightWheelMode = LightWheelMode.ON,
484
+ ) -> None:
485
+ """Set the light wheel (color cycle / rainbow loop) mode for a light zone.
486
+
487
+ Args:
488
+ ----
489
+ zone: The light zone number (1-based).
490
+ mode: The light wheel mode. Use LightWheelMode enum values or string,
491
+ e.g. ``LightWheelMode.ON.value``, ``"loopUp"``,
492
+ ``"loopDown"``, or ``"off"``. Defaults to LightWheelMode.ON.
493
+
494
+ """
495
+ mode_val = mode.value if isinstance(mode, LightWheelMode) else str(mode)
496
+ await self._send_command(
497
+ {
498
+ "lights": {
499
+ "control": {
500
+ f"Zone{zone}": {
501
+ "control": {
502
+ "lightWheel": mode_val,
410
503
  }
411
504
  }
412
505
  }
@@ -430,7 +523,19 @@ class HotSpring:
430
523
  green: Green value (0-255).
431
524
  blue: Blue value (0-255).
432
525
 
526
+ Raises:
527
+ ------
528
+ ValueError: If any RGB component is not between 0 and 255.
529
+
433
530
  """
531
+ for component in (red, green, blue):
532
+ if not 0 <= component <= 255:
533
+ msg = (
534
+ f"RGB values must be between 0 and 255, "
535
+ f"got ({red}, {green}, {blue})"
536
+ )
537
+ raise ValueError(msg)
538
+
434
539
  await self._send_command(
435
540
  {
436
541
  "lights": {