python-hotspring 1.3.0__tar.gz → 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hotspring
3
- Version: 1.3.0
3
+ Version: 2.0.0
4
4
  Summary: Asynchronous Python client for Hot Spring Connected Spa Kit 2.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -22,7 +22,7 @@ packages = [
22
22
  ]
23
23
  readme = "README.md"
24
24
  repository = "https://github.com/Moustachauve/python-hotspring"
25
- version = "1.3.0"
25
+ version = "2.0.0"
26
26
 
27
27
  [tool.poetry.dependencies]
28
28
  aiohttp = ">=3.0.0"
@@ -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
  ----
@@ -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": {