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.
- {python_hotspring-1.3.0 → python_hotspring-2.0.0}/PKG-INFO +1 -1
- {python_hotspring-1.3.0 → python_hotspring-2.0.0}/pyproject.toml +1 -1
- {python_hotspring-1.3.0 → python_hotspring-2.0.0}/src/hotspring/const.py +2 -3
- {python_hotspring-1.3.0 → python_hotspring-2.0.0}/src/hotspring/hotspring.py +159 -54
- python_hotspring-2.0.0/src/hotspring/models.py +1309 -0
- python_hotspring-1.3.0/src/hotspring/models.py +0 -901
- {python_hotspring-1.3.0 → python_hotspring-2.0.0}/LICENSE +0 -0
- {python_hotspring-1.3.0 → python_hotspring-2.0.0}/README.md +0 -0
- {python_hotspring-1.3.0 → python_hotspring-2.0.0}/src/hotspring/__init__.py +0 -0
- {python_hotspring-1.3.0 → python_hotspring-2.0.0}/src/hotspring/exceptions.py +0 -0
- {python_hotspring-1.3.0 → python_hotspring-2.0.0}/src/hotspring/py.typed +0 -0
|
@@ -82,8 +82,7 @@ class LightColor(Enum):
|
|
|
82
82
|
"""
|
|
83
83
|
|
|
84
84
|
UNKNOWN = "unknown"
|
|
85
|
-
|
|
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
|
|
148
|
-
"""
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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": {
|