python-hotspring 1.3.0__tar.gz → 2.0.1__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.1
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.1"
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,109 @@ 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 queries /status and /spaConnectStatus
170
+ concurrently, avoiding redundant radio (LoRA) queries for static identity data
171
+ while keeping telemetry and connection status fresh.
172
+
173
+ Args:
174
+ ----
175
+ refresh_identity: Force re-fetching static identity from /startup
176
+ and /spamodel.
177
+
178
+ Returns:
155
179
  -------
156
180
  The updated Spa data object.
157
181
 
158
- Raises
182
+ Raises:
159
183
  ------
160
184
  HotSpringError: If no data is returned from the spa.
161
185
 
162
186
  """
163
- # Fetch main status
164
- status_data = await self.request("/status")
187
+ if not self._identity_loaded or refresh_identity:
188
+ status_res, startup_res, connect_res, model_res = await asyncio.gather(
189
+ self.request("/status"),
190
+ self._safe_request("/startup"),
191
+ self._safe_request("/spaConnectStatus"),
192
+ self._safe_request("/spamodel"),
193
+ )
165
194
 
166
- if self.spa is None:
167
- self.spa = Spa(status_data)
195
+ if self.spa is None:
196
+ self.spa = Spa(status_res)
197
+ else:
198
+ self.spa.update_from_dict(status_res)
199
+
200
+ if startup_res:
201
+ self.spa.update_info(startup_res)
202
+
203
+ if model_res:
204
+ self.spa.update_info(model_res)
205
+
206
+ if connect_res:
207
+ self.spa.update_connection_status(connect_res)
208
+
209
+ self._identity_loaded = True
210
+ return self.spa
211
+
212
+ status_res, connect_res = await asyncio.gather(
213
+ self.request("/status"),
214
+ self._safe_request("/spaConnectStatus"),
215
+ )
216
+
217
+ if self.spa is None: # Safety guard; spa is always set after cold sync
218
+ self.spa = Spa(status_res)
168
219
  else:
169
- self.spa.update_from_dict(status_data)
220
+ self.spa.update_from_dict(status_res)
170
221
 
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
222
+ if connect_res:
223
+ self.spa.update_connection_status(connect_res)
178
224
 
179
- try:
180
- model_data = await self.request("/spamodel")
181
- identity_data.update(model_data)
182
- except HotSpringError:
183
- pass
225
+ return self.spa
226
+
227
+ async def update_identity(self) -> SpaInfo:
228
+ """Fetch and update static spa identity info (/startup and /spamodel).
229
+
230
+ Returns
231
+ -------
232
+ The updated SpaInfo data.
233
+
234
+ Raises
235
+ ------
236
+ HotSpringError: If the spa has not been initialized with update().
237
+
238
+ """
239
+ if self.spa is None:
240
+ msg = "Call update() before update_identity()"
241
+ raise HotSpringError(msg)
242
+
243
+ startup_res, model_res = await asyncio.gather(
244
+ self._safe_request("/startup"),
245
+ self._safe_request("/spamodel"),
246
+ )
247
+
248
+ identity_data: dict[str, object] = {}
249
+ if startup_res:
250
+ identity_data.update(startup_res)
251
+ if model_res:
252
+ identity_data.update(model_res)
184
253
 
185
254
  if identity_data:
186
255
  self.spa.update_info(identity_data)
187
256
 
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
257
+ self._identity_loaded = True
258
+ return self.spa.info
196
259
 
197
260
  async def update_water_care(self) -> FreshWaterIQ:
198
261
  """Update FreshWater IQ water quality data.
@@ -292,7 +355,11 @@ class HotSpring:
292
355
  raise HotSpringNotReadyError(msg)
293
356
 
294
357
  try:
295
- await self.request("/spaManager", method="POST", data=payload)
358
+ response_data = await self.request(
359
+ "/spaManager", method="POST", data=payload
360
+ )
361
+ if self.spa is not None:
362
+ self.spa.update_from_dict(response_data)
296
363
  except HotSpringError as exception:
297
364
  msg = f"Command failed: {payload}"
298
365
  raise HotSpringCommandError(msg) from exception
@@ -310,57 +377,53 @@ class HotSpring:
310
377
  {"heater": {"control": {"temperatureABS": str(temperature)}}}
311
378
  )
312
379
 
313
- async def set_heating_mode(self, mode: str) -> None:
380
+ async def set_heating_mode(self, mode: str | HeatingMode) -> None:
314
381
  """Set the heating mode.
315
382
 
316
383
  Args:
317
384
  ----
318
- mode: The heating mode value. Use HeatingMode enum values,
319
- e.g. ``HeatingMode.HEAT_WITH_BOOST.value``.
385
+ mode: The heating mode value. Use HeatingMode enum values or string,
386
+ e.g. ``HeatingMode.HEAT_WITH_BOOST.value`` or ``"heatWithBoost"``.
320
387
 
321
388
  """
322
- await self._send_command({"heater": {"control": {"heatingMode": mode}}})
389
+ mode_val = mode.value if isinstance(mode, HeatingMode) else str(mode)
390
+ await self._send_command({"heater": {"control": {"heatingMode": mode_val}}})
323
391
 
324
- async def set_jet(self, jet: int, speed: str) -> None:
392
+ async def set_jet(self, jet: int, speed: str | JetSpeed) -> None:
325
393
  """Set the speed of a jet pump.
326
394
 
327
395
  Args:
328
396
  ----
329
397
  jet: The jet number (1-based).
330
- speed: The speed value. Use JetSpeed enum values,
331
- e.g. ``JetSpeed.HIGH_SPEED.value``.
398
+ speed: The speed value. Use JetSpeed enum values or string,
399
+ e.g. ``JetSpeed.HIGH_SPEED.value`` or ``"highSpeed"``.
332
400
 
333
401
  """
334
- await self._send_command({"JET": {f"JET{jet}": {"control": speed}}})
402
+ speed_val = speed.value if isinstance(speed, JetSpeed) else str(speed)
403
+ await self._send_command({"JET": {f"JET{jet}": {"control": speed_val}}})
335
404
 
336
405
  async def set_light_color(
337
406
  self,
338
407
  zone: int,
339
- color: str,
340
- intensity: int = 5,
341
- light_wheel: str = "off",
408
+ color: str | LightColor,
342
409
  ) -> None:
343
410
  """Set the color of a light zone.
344
411
 
345
412
  Args:
346
413
  ----
347
414
  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".
415
+ color: The color value. Use LightColor enum values or string,
416
+ e.g. ``LightColor.BLUE.value`` or ``"BLUE"``.
353
417
 
354
418
  """
419
+ color_val = color.value if isinstance(color, LightColor) else str(color)
355
420
  await self._send_command(
356
421
  {
357
422
  "lights": {
358
423
  "control": {
359
424
  f"Zone{zone}": {
360
425
  "control": {
361
- "color": color.upper(),
362
- "IntensityAbs": intensity,
363
- "lightWheel": light_wheel,
426
+ "color": color_val.upper(),
364
427
  }
365
428
  }
366
429
  }
@@ -390,23 +453,60 @@ class HotSpring:
390
453
  }
391
454
  )
392
455
 
393
- async def set_light_brightness(self, zone: int, *, full: bool = True) -> None:
394
- """Set the brightness of a light zone.
456
+ async def set_light_brightness(self, zone: int, brightness: int) -> None:
457
+ """Set the brightness intensity of a light zone (0-5).
458
+
459
+ Args:
460
+ ----
461
+ zone: The light zone number (1-based).
462
+ brightness: The brightness level (0 = off, 1 = lowest, 5 = maximum).
463
+
464
+ Raises:
465
+ ------
466
+ ValueError: If brightness is not an integer between 0 and 5.
467
+
468
+ """
469
+ if not 0 <= brightness <= 5:
470
+ msg = f"Brightness must be between 0 and 5, got {brightness}"
471
+ raise ValueError(msg)
472
+
473
+ await self._send_command(
474
+ {
475
+ "lights": {
476
+ "control": {
477
+ f"Zone{zone}": {
478
+ "control": {
479
+ "IntensityAbs": brightness,
480
+ }
481
+ }
482
+ }
483
+ }
484
+ }
485
+ )
486
+
487
+ async def set_light_wheel(
488
+ self,
489
+ zone: int,
490
+ mode: str | LightWheelMode = LightWheelMode.ON,
491
+ ) -> None:
492
+ """Set the light wheel (color cycle / rainbow loop) mode for a light zone.
395
493
 
396
494
  Args:
397
495
  ----
398
496
  zone: The light zone number (1-based).
399
- full: True to set to full brightness ("fullon"), False to turn off ("off").
497
+ mode: The light wheel mode. Use LightWheelMode enum values or string,
498
+ e.g. ``LightWheelMode.ON.value``, ``"loopUp"``,
499
+ ``"loopDown"``, or ``"off"``. Defaults to LightWheelMode.ON.
400
500
 
401
501
  """
402
- intensity = "fullon" if full else "off"
502
+ mode_val = mode.value if isinstance(mode, LightWheelMode) else str(mode)
403
503
  await self._send_command(
404
504
  {
405
505
  "lights": {
406
506
  "control": {
407
507
  f"Zone{zone}": {
408
508
  "control": {
409
- "Intensity": intensity,
509
+ "lightWheel": mode_val,
410
510
  }
411
511
  }
412
512
  }
@@ -430,7 +530,19 @@ class HotSpring:
430
530
  green: Green value (0-255).
431
531
  blue: Blue value (0-255).
432
532
 
533
+ Raises:
534
+ ------
535
+ ValueError: If any RGB component is not between 0 and 255.
536
+
433
537
  """
538
+ for component in (red, green, blue):
539
+ if not 0 <= component <= 255:
540
+ msg = (
541
+ f"RGB values must be between 0 and 255, "
542
+ f"got ({red}, {green}, {blue})"
543
+ )
544
+ raise ValueError(msg)
545
+
434
546
  await self._send_command(
435
547
  {
436
548
  "lights": {