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.
- {python_hotspring-1.3.0 → python_hotspring-2.0.1}/PKG-INFO +1 -1
- {python_hotspring-1.3.0 → python_hotspring-2.0.1}/pyproject.toml +1 -1
- {python_hotspring-1.3.0 → python_hotspring-2.0.1}/src/hotspring/const.py +2 -3
- {python_hotspring-1.3.0 → python_hotspring-2.0.1}/src/hotspring/hotspring.py +169 -57
- python_hotspring-2.0.1/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.1}/LICENSE +0 -0
- {python_hotspring-1.3.0 → python_hotspring-2.0.1}/README.md +0 -0
- {python_hotspring-1.3.0 → python_hotspring-2.0.1}/src/hotspring/__init__.py +0 -0
- {python_hotspring-1.3.0 → python_hotspring-2.0.1}/src/hotspring/exceptions.py +0 -0
- {python_hotspring-1.3.0 → python_hotspring-2.0.1}/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,109 @@ 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 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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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(
|
|
220
|
+
self.spa.update_from_dict(status_res)
|
|
170
221
|
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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": {
|