python-hotspring 2.1.0__py3-none-any.whl → 3.0.1__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 +6 -0
- hotspring/const.py +71 -2
- hotspring/hotspring.py +309 -72
- hotspring/models.py +317 -112
- {python_hotspring-2.1.0.dist-info → python_hotspring-3.0.1.dist-info}/METADATA +9 -9
- python_hotspring-3.0.1.dist-info/RECORD +10 -0
- {python_hotspring-2.1.0.dist-info → python_hotspring-3.0.1.dist-info}/WHEEL +1 -1
- python_hotspring-2.1.0.dist-info/RECORD +0 -10
- {python_hotspring-2.1.0.dist-info → python_hotspring-3.0.1.dist-info}/licenses/LICENSE +0 -0
hotspring/__init__.py
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
from .const import (
|
|
4
4
|
BrightnessLevel,
|
|
5
5
|
DeviceType,
|
|
6
|
+
EnergySavingMode,
|
|
6
7
|
HeatingMode,
|
|
7
8
|
JetSpeed,
|
|
9
|
+
JetSpeedType,
|
|
8
10
|
LightColor,
|
|
9
11
|
LightWheelMode,
|
|
10
12
|
SpaBrand,
|
|
@@ -35,6 +37,7 @@ from .models import (
|
|
|
35
37
|
Spa,
|
|
36
38
|
SpaInfo,
|
|
37
39
|
SpaLock,
|
|
40
|
+
SpaTestData,
|
|
38
41
|
Versions,
|
|
39
42
|
WaterCare,
|
|
40
43
|
)
|
|
@@ -47,6 +50,7 @@ __all__ = [
|
|
|
47
50
|
"DeviceType",
|
|
48
51
|
"Diagnostics",
|
|
49
52
|
"EnergySaving",
|
|
53
|
+
"EnergySavingMode",
|
|
50
54
|
"FreshWaterIQ",
|
|
51
55
|
"Heater",
|
|
52
56
|
"HeatingMode",
|
|
@@ -60,6 +64,7 @@ __all__ = [
|
|
|
60
64
|
"HotSpringSNADetectedError",
|
|
61
65
|
"Jet",
|
|
62
66
|
"JetSpeed",
|
|
67
|
+
"JetSpeedType",
|
|
63
68
|
"LightColor",
|
|
64
69
|
"LightWheelMode",
|
|
65
70
|
"LightZone",
|
|
@@ -69,6 +74,7 @@ __all__ = [
|
|
|
69
74
|
"SpaFailureState",
|
|
70
75
|
"SpaInfo",
|
|
71
76
|
"SpaLock",
|
|
77
|
+
"SpaTestData",
|
|
72
78
|
"TemperatureUnit",
|
|
73
79
|
"Versions",
|
|
74
80
|
"WaterCare",
|
hotspring/const.py
CHANGED
|
@@ -75,6 +75,35 @@ class JetSpeed(Enum):
|
|
|
75
75
|
_JET_SPEED_MAP: dict[str, JetSpeed] = {s.value: s for s in JetSpeed}
|
|
76
76
|
|
|
77
77
|
|
|
78
|
+
class JetSpeedType(Enum):
|
|
79
|
+
"""Configured speed capability of a jet pump."""
|
|
80
|
+
|
|
81
|
+
UNKNOWN = "unknown"
|
|
82
|
+
SINGLE_SPEED = "singleSpeed"
|
|
83
|
+
DUAL_SPEED = "dualSpeed"
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def build(cls, value: str | None) -> JetSpeedType:
|
|
87
|
+
"""Parse a raw API string into a JetSpeedType.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
----
|
|
91
|
+
value: The raw speed configuration string from the API, or None.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
-------
|
|
95
|
+
The matching JetSpeedType, or JetSpeedType.UNKNOWN for
|
|
96
|
+
unrecognized values.
|
|
97
|
+
|
|
98
|
+
"""
|
|
99
|
+
if value is None:
|
|
100
|
+
return cls.UNKNOWN
|
|
101
|
+
return _JET_SPEED_TYPE_MAP.get(value, cls.UNKNOWN)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_JET_SPEED_TYPE_MAP: dict[str, JetSpeedType] = {t.value: t for t in JetSpeedType}
|
|
105
|
+
|
|
106
|
+
|
|
78
107
|
class LightColor(Enum):
|
|
79
108
|
"""Color setting for a spa light zone.
|
|
80
109
|
|
|
@@ -153,12 +182,13 @@ class BrightnessLevel(Enum):
|
|
|
153
182
|
"""
|
|
154
183
|
|
|
155
184
|
UNKNOWN = "unknown"
|
|
185
|
+
AUTO = "auto"
|
|
156
186
|
LEVEL_1 = "brightness_level_1"
|
|
157
187
|
LEVEL_2 = "brightness_level_2"
|
|
158
188
|
LEVEL_3 = "brightness_level_3"
|
|
159
189
|
|
|
160
190
|
@classmethod
|
|
161
|
-
def build(cls, value: str | None) -> BrightnessLevel:
|
|
191
|
+
def build(cls, value: str | int | None) -> BrightnessLevel:
|
|
162
192
|
"""Parse a raw API string into a BrightnessLevel.
|
|
163
193
|
|
|
164
194
|
Args:
|
|
@@ -173,10 +203,49 @@ class BrightnessLevel(Enum):
|
|
|
173
203
|
"""
|
|
174
204
|
if value is None:
|
|
175
205
|
return cls.UNKNOWN
|
|
176
|
-
|
|
206
|
+
val_str = str(value).strip().lower()
|
|
207
|
+
return _BRIGHTNESS_MAP.get(val_str, cls.UNKNOWN)
|
|
177
208
|
|
|
178
209
|
|
|
179
210
|
_BRIGHTNESS_MAP: dict[str, BrightnessLevel] = {b.value: b for b in BrightnessLevel}
|
|
211
|
+
_BRIGHTNESS_MAP.update(
|
|
212
|
+
{
|
|
213
|
+
"1": BrightnessLevel.LEVEL_1,
|
|
214
|
+
"2": BrightnessLevel.LEVEL_2,
|
|
215
|
+
"3": BrightnessLevel.LEVEL_3,
|
|
216
|
+
"auto": BrightnessLevel.AUTO,
|
|
217
|
+
}
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
_BRIGHTNESS_TO_WIRE: dict[BrightnessLevel, str] = {
|
|
221
|
+
BrightnessLevel.LEVEL_1: "1",
|
|
222
|
+
BrightnessLevel.LEVEL_2: "2",
|
|
223
|
+
BrightnessLevel.LEVEL_3: "3",
|
|
224
|
+
BrightnessLevel.AUTO: "auto",
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
MAX_ENERGY_SAVING_SCHEDULES = 2
|
|
228
|
+
VALID_ENERGY_SAVING_SCHEDULE_IDS = (1, 2)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class EnergySavingMode(Enum):
|
|
232
|
+
"""Energy saving schedule mode."""
|
|
233
|
+
|
|
234
|
+
UNKNOWN = "unknown"
|
|
235
|
+
OFF = "off"
|
|
236
|
+
ON = "on"
|
|
237
|
+
|
|
238
|
+
@classmethod
|
|
239
|
+
def build(cls, value: object) -> EnergySavingMode:
|
|
240
|
+
"""Parse raw mode from API into EnergySavingMode."""
|
|
241
|
+
if value is None:
|
|
242
|
+
return cls.UNKNOWN
|
|
243
|
+
val_str = str(value).strip().lower()
|
|
244
|
+
if val_str in ("1", "on", "enable", "true"):
|
|
245
|
+
return cls.ON
|
|
246
|
+
if val_str in ("0", "off", "disable", "false"):
|
|
247
|
+
return cls.OFF
|
|
248
|
+
return cls.UNKNOWN
|
|
180
249
|
|
|
181
250
|
|
|
182
251
|
class TemperatureUnit(Enum):
|
hotspring/hotspring.py
CHANGED
|
@@ -9,10 +9,12 @@ from dataclasses import dataclass
|
|
|
9
9
|
from typing import Self
|
|
10
10
|
|
|
11
11
|
import aiohttp
|
|
12
|
-
import backoff
|
|
13
12
|
from yarl import URL
|
|
14
13
|
|
|
15
14
|
from .const import (
|
|
15
|
+
_BRIGHTNESS_TO_WIRE,
|
|
16
|
+
VALID_ENERGY_SAVING_SCHEDULE_IDS,
|
|
17
|
+
BrightnessLevel,
|
|
16
18
|
HeatingMode,
|
|
17
19
|
JetSpeed,
|
|
18
20
|
LightColor,
|
|
@@ -36,7 +38,7 @@ from .models import (
|
|
|
36
38
|
|
|
37
39
|
|
|
38
40
|
@dataclass
|
|
39
|
-
class HotSpring:
|
|
41
|
+
class HotSpring: # pylint: disable=too-many-public-methods
|
|
40
42
|
"""Main class for handling connections with a Hot Spring Spa.
|
|
41
43
|
|
|
42
44
|
The Hot Spring Connected Spa Kit 2 uses a Home Network Adapter (HNA)
|
|
@@ -55,17 +57,12 @@ class HotSpring:
|
|
|
55
57
|
host: str
|
|
56
58
|
session: aiohttp.ClientSession | None = None
|
|
57
59
|
request_timeout: float = 10.0
|
|
60
|
+
request_retries: int = 0
|
|
58
61
|
validate_device: bool = True
|
|
59
62
|
_close_session: bool = False
|
|
60
63
|
_identity_loaded: bool = False
|
|
61
64
|
spa: Spa | None = None
|
|
62
65
|
|
|
63
|
-
@backoff.on_exception(
|
|
64
|
-
backoff.expo,
|
|
65
|
-
HotSpringConnectionError,
|
|
66
|
-
max_tries=3,
|
|
67
|
-
logger=None,
|
|
68
|
-
)
|
|
69
66
|
async def request(
|
|
70
67
|
self,
|
|
71
68
|
uri: str = "",
|
|
@@ -75,7 +72,8 @@ class HotSpring:
|
|
|
75
72
|
"""Handle a request to the Hot Spring HNA.
|
|
76
73
|
|
|
77
74
|
A generic method for sending/handling HTTP requests done against
|
|
78
|
-
the Hot Spring Home Network Adapter.
|
|
75
|
+
the Hot Spring Home Network Adapter. If `request_retries > 0`, failed
|
|
76
|
+
connection attempts are retried using exponential backoff.
|
|
79
77
|
|
|
80
78
|
Args:
|
|
81
79
|
----
|
|
@@ -107,53 +105,65 @@ class HotSpring:
|
|
|
107
105
|
self.session = aiohttp.ClientSession()
|
|
108
106
|
self._close_session = True
|
|
109
107
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
contents = await response.read()
|
|
121
|
-
response.close()
|
|
108
|
+
attempts = 0
|
|
109
|
+
while True:
|
|
110
|
+
try:
|
|
111
|
+
async with asyncio.timeout(self.request_timeout):
|
|
112
|
+
response = await self.session.request(
|
|
113
|
+
method,
|
|
114
|
+
url,
|
|
115
|
+
json=data,
|
|
116
|
+
headers=headers,
|
|
117
|
+
)
|
|
122
118
|
|
|
119
|
+
if response.status // 100 in [4, 5]:
|
|
120
|
+
contents = await response.read()
|
|
121
|
+
response.close()
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
raise HotSpringError(
|
|
125
|
+
response.status,
|
|
126
|
+
json.loads(contents.decode("utf8")),
|
|
127
|
+
)
|
|
128
|
+
except json.JSONDecodeError:
|
|
129
|
+
raise HotSpringError(
|
|
130
|
+
response.status,
|
|
131
|
+
{"message": contents.decode("utf8")},
|
|
132
|
+
) from None
|
|
133
|
+
|
|
134
|
+
# The spa returns JSON with text/html Content-Type,
|
|
135
|
+
# so always try JSON parsing first.
|
|
136
|
+
body = await response.text()
|
|
123
137
|
try:
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
138
|
+
response_data = json.loads(body)
|
|
139
|
+
except json.JSONDecodeError as exc:
|
|
140
|
+
msg = f"Invalid JSON response from {uri}: {body[:200]}"
|
|
141
|
+
raise HotSpringError(msg) from exc
|
|
142
|
+
|
|
143
|
+
if not isinstance(response_data, dict):
|
|
144
|
+
msg = f"Unexpected response type from {uri}"
|
|
145
|
+
raise HotSpringError(msg)
|
|
146
|
+
|
|
147
|
+
except asyncio.TimeoutError as exception: # noqa: PERF203
|
|
148
|
+
attempts += 1
|
|
149
|
+
if attempts > self.request_retries:
|
|
150
|
+
msg = (
|
|
151
|
+
f"Timeout occurred while connecting to Hot Spring HNA at "
|
|
152
|
+
f"{self.host}"
|
|
127
153
|
)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
raise HotSpringError(msg) from exc
|
|
142
|
-
|
|
143
|
-
except asyncio.TimeoutError as exception:
|
|
144
|
-
msg = f"Timeout occurred while connecting to Hot Spring HNA at {self.host}"
|
|
145
|
-
raise HotSpringConnectionTimeoutError(msg) from exception
|
|
146
|
-
except aiohttp.ClientError as exception:
|
|
147
|
-
msg = (
|
|
148
|
-
f"Error occurred while communicating with Hot Spring HNA at {self.host}"
|
|
149
|
-
)
|
|
150
|
-
raise HotSpringConnectionError(msg) from exception
|
|
151
|
-
|
|
152
|
-
if not isinstance(response_data, dict):
|
|
153
|
-
msg = f"Unexpected response type from {uri}"
|
|
154
|
-
raise HotSpringError(msg)
|
|
155
|
-
|
|
156
|
-
return response_data
|
|
154
|
+
raise HotSpringConnectionTimeoutError(msg) from exception
|
|
155
|
+
await asyncio.sleep(0.5 * (2 ** (attempts - 1)))
|
|
156
|
+
except aiohttp.ClientError as exception:
|
|
157
|
+
attempts += 1
|
|
158
|
+
if attempts > self.request_retries:
|
|
159
|
+
msg = (
|
|
160
|
+
f"Error occurred while communicating with Hot Spring HNA at "
|
|
161
|
+
f"{self.host}"
|
|
162
|
+
)
|
|
163
|
+
raise HotSpringConnectionError(msg) from exception
|
|
164
|
+
await asyncio.sleep(0.5 * (2 ** (attempts - 1)))
|
|
165
|
+
else:
|
|
166
|
+
return response_data
|
|
157
167
|
|
|
158
168
|
async def _safe_request(self, uri: str) -> dict[str, object] | None:
|
|
159
169
|
"""Fetch an endpoint, returning None on error."""
|
|
@@ -165,11 +175,12 @@ class HotSpring:
|
|
|
165
175
|
"""Get all spa information.
|
|
166
176
|
|
|
167
177
|
On the initial call (or when `refresh_identity=True`), this method fetches
|
|
168
|
-
the main /status endpoint
|
|
169
|
-
and /spamodel.
|
|
178
|
+
the main /status endpoint sequentially followed by /startup, /spaConnectStatus,
|
|
179
|
+
and /spamodel. Sequential execution prevents socket exhaustion and LoRA radio
|
|
180
|
+
congestion on the ESP32.
|
|
170
181
|
|
|
171
182
|
On subsequent routine polling cycles, it queries /status and /spaConnectStatus
|
|
172
|
-
|
|
183
|
+
sequentially, avoiding redundant radio (LoRA) queries for static identity data
|
|
173
184
|
while keeping telemetry and connection status fresh.
|
|
174
185
|
|
|
175
186
|
Args:
|
|
@@ -189,12 +200,10 @@ class HotSpring:
|
|
|
189
200
|
|
|
190
201
|
"""
|
|
191
202
|
if not self._identity_loaded or refresh_identity:
|
|
192
|
-
status_res
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
self._safe_request("/spamodel"),
|
|
197
|
-
)
|
|
203
|
+
status_res = await self.request("/status")
|
|
204
|
+
startup_res = await self._safe_request("/startup")
|
|
205
|
+
connect_res = await self._safe_request("/spaConnectStatus")
|
|
206
|
+
model_res = await self._safe_request("/spamodel")
|
|
198
207
|
|
|
199
208
|
if self.spa is None:
|
|
200
209
|
self.spa = Spa(status_res)
|
|
@@ -209,6 +218,11 @@ class HotSpring:
|
|
|
209
218
|
|
|
210
219
|
if connect_res:
|
|
211
220
|
self.spa.update_connection_status(connect_res)
|
|
221
|
+
elif (
|
|
222
|
+
self.spa.connection_status
|
|
223
|
+
and not self.spa.connection_status.spa_connected
|
|
224
|
+
):
|
|
225
|
+
self.spa.connection_status.spa_connected = True
|
|
212
226
|
|
|
213
227
|
if self.validate_device and self.spa.info.is_sna:
|
|
214
228
|
msg = (
|
|
@@ -222,10 +236,8 @@ class HotSpring:
|
|
|
222
236
|
self._identity_loaded = True
|
|
223
237
|
return self.spa
|
|
224
238
|
|
|
225
|
-
status_res
|
|
226
|
-
|
|
227
|
-
self._safe_request("/spaConnectStatus"),
|
|
228
|
-
)
|
|
239
|
+
status_res = await self.request("/status")
|
|
240
|
+
connect_res = await self._safe_request("/spaConnectStatus")
|
|
229
241
|
|
|
230
242
|
if self.spa is None: # Safety guard; spa is always set after cold sync
|
|
231
243
|
self.spa = Spa(status_res)
|
|
@@ -234,6 +246,10 @@ class HotSpring:
|
|
|
234
246
|
|
|
235
247
|
if connect_res:
|
|
236
248
|
self.spa.update_connection_status(connect_res)
|
|
249
|
+
elif (
|
|
250
|
+
self.spa.connection_status and not self.spa.connection_status.spa_connected
|
|
251
|
+
):
|
|
252
|
+
self.spa.connection_status.spa_connected = True
|
|
237
253
|
|
|
238
254
|
return self.spa
|
|
239
255
|
|
|
@@ -275,10 +291,8 @@ class HotSpring:
|
|
|
275
291
|
msg = "Call update() before update_identity()"
|
|
276
292
|
raise HotSpringError(msg)
|
|
277
293
|
|
|
278
|
-
startup_res
|
|
279
|
-
|
|
280
|
-
self._safe_request("/spamodel"),
|
|
281
|
-
)
|
|
294
|
+
startup_res = await self._safe_request("/startup")
|
|
295
|
+
model_res = await self._safe_request("/spamodel")
|
|
282
296
|
|
|
283
297
|
identity_data: dict[str, object] = {}
|
|
284
298
|
if startup_res:
|
|
@@ -408,13 +422,13 @@ class HotSpring:
|
|
|
408
422
|
msg = f"Command failed: {payload}"
|
|
409
423
|
raise HotSpringCommandError(msg) from exception
|
|
410
424
|
|
|
411
|
-
async def set_temperature(self, temperature:
|
|
425
|
+
async def set_temperature(self, temperature: float) -> None:
|
|
412
426
|
"""Set the target water temperature.
|
|
413
427
|
|
|
414
428
|
Args:
|
|
415
429
|
----
|
|
416
430
|
temperature: Target temperature in the spa's configured unit
|
|
417
|
-
(Fahrenheit or Celsius).
|
|
431
|
+
(Fahrenheit or Celsius). Supports integer or half-degree float values.
|
|
418
432
|
|
|
419
433
|
"""
|
|
420
434
|
await self._send_command(
|
|
@@ -627,6 +641,229 @@ class HotSpring:
|
|
|
627
641
|
value = "on" if on else "off"
|
|
628
642
|
await self._send_command({"blower": {"control": value}})
|
|
629
643
|
|
|
644
|
+
async def set_spa_lock(self, *, locked: bool) -> None:
|
|
645
|
+
"""Lock or unlock all spa controls (Spa Lock).
|
|
646
|
+
|
|
647
|
+
Args:
|
|
648
|
+
----
|
|
649
|
+
locked: True to lock, False to unlock.
|
|
650
|
+
|
|
651
|
+
"""
|
|
652
|
+
value = "on" if locked else "off"
|
|
653
|
+
await self._send_command({"spaLock": {"control": value}})
|
|
654
|
+
|
|
655
|
+
async def set_temperature_lock(self, *, locked: bool) -> None:
|
|
656
|
+
"""Lock or unlock the spa heater temperature setting (Temperature Lock).
|
|
657
|
+
|
|
658
|
+
Args:
|
|
659
|
+
----
|
|
660
|
+
locked: True to lock, False to unlock.
|
|
661
|
+
|
|
662
|
+
"""
|
|
663
|
+
value = "on" if locked else "off"
|
|
664
|
+
await self._send_command({"heater": {"control": {"temperatureLock": value}}})
|
|
665
|
+
|
|
666
|
+
async def set_heater_lock(self, *, locked: bool) -> None:
|
|
667
|
+
"""Alias for set_temperature_lock."""
|
|
668
|
+
await self.set_temperature_lock(locked=locked)
|
|
669
|
+
|
|
670
|
+
async def set_vanishing_act(self, *, enabled: bool) -> None:
|
|
671
|
+
"""Enable or disable Vanishing Act (calcium remover cycle).
|
|
672
|
+
|
|
673
|
+
Args:
|
|
674
|
+
----
|
|
675
|
+
enabled: True to enable, False to disable.
|
|
676
|
+
|
|
677
|
+
"""
|
|
678
|
+
value = "on" if enabled else "off"
|
|
679
|
+
await self._send_command({"cleanCycle": {"control": {"vanishingAct": value}}})
|
|
680
|
+
|
|
681
|
+
async def toggle_water_care_boost(self) -> None:
|
|
682
|
+
"""Trigger or toggle the water care chlorine boost.
|
|
683
|
+
|
|
684
|
+
Note:
|
|
685
|
+
----
|
|
686
|
+
The underlying ESP32 firmware endpoint only supports toggling
|
|
687
|
+
the boost state via ``{"waterCare": {"control": {"boost": "toggle"}}}``.
|
|
688
|
+
|
|
689
|
+
"""
|
|
690
|
+
await self._send_command({"waterCare": {"control": {"boost": "toggle"}}})
|
|
691
|
+
|
|
692
|
+
async def set_water_care_boost(self) -> None:
|
|
693
|
+
"""Alias for toggle_water_care_boost."""
|
|
694
|
+
await self.toggle_water_care_boost()
|
|
695
|
+
|
|
696
|
+
async def set_water_care_level(self, level: int) -> None:
|
|
697
|
+
"""Set the salt water care cartridge output level (0-10).
|
|
698
|
+
|
|
699
|
+
Args:
|
|
700
|
+
----
|
|
701
|
+
level: The output level (0 = off/disabled, 1-10 = active output).
|
|
702
|
+
|
|
703
|
+
Raises:
|
|
704
|
+
------
|
|
705
|
+
ValueError: If level is not an integer between 0 and 10.
|
|
706
|
+
|
|
707
|
+
"""
|
|
708
|
+
if not 0 <= level <= 10:
|
|
709
|
+
msg = f"Water care level must be between 0 and 10, got {level}"
|
|
710
|
+
raise ValueError(msg)
|
|
711
|
+
await self._send_command({"waterCare": {"control": {"level": str(level)}}})
|
|
712
|
+
|
|
713
|
+
async def set_salt_system_power(
|
|
714
|
+
self,
|
|
715
|
+
*,
|
|
716
|
+
power_a: bool | None = None,
|
|
717
|
+
power_b: bool | None = None,
|
|
718
|
+
) -> None:
|
|
719
|
+
"""Set the salt system power configuration states.
|
|
720
|
+
|
|
721
|
+
Args:
|
|
722
|
+
----
|
|
723
|
+
power_a: Enable or disable saltSystemPowerA, or None to leave unchanged.
|
|
724
|
+
power_b: Enable or disable saltSystemPowerB, or None to leave unchanged.
|
|
725
|
+
|
|
726
|
+
Raises:
|
|
727
|
+
------
|
|
728
|
+
ValueError: If neither power_a nor power_b is specified.
|
|
729
|
+
|
|
730
|
+
"""
|
|
731
|
+
cfg: dict[str, object] = {}
|
|
732
|
+
if power_a is not None:
|
|
733
|
+
cfg["saltSystemPowerA"] = "enable" if power_a else "disable"
|
|
734
|
+
if power_b is not None:
|
|
735
|
+
cfg["saltSystemPowerB"] = "enable" if power_b else "disable"
|
|
736
|
+
if not cfg:
|
|
737
|
+
msg = "At least one of power_a or power_b must be provided"
|
|
738
|
+
raise ValueError(msg)
|
|
739
|
+
await self._send_command({"waterCare": {"config": cfg}})
|
|
740
|
+
|
|
741
|
+
async def set_logo_light(self, level: BrightnessLevel | str | int) -> None:
|
|
742
|
+
"""Set the brightness level of the spa logo light.
|
|
743
|
+
|
|
744
|
+
Args:
|
|
745
|
+
----
|
|
746
|
+
level: BrightnessLevel enum, integer (1-3), or string
|
|
747
|
+
("1", "2", "3", "auto").
|
|
748
|
+
|
|
749
|
+
Raises:
|
|
750
|
+
------
|
|
751
|
+
ValueError: If the brightness level is unrecognized.
|
|
752
|
+
|
|
753
|
+
"""
|
|
754
|
+
brightness = (
|
|
755
|
+
level
|
|
756
|
+
if isinstance(level, BrightnessLevel)
|
|
757
|
+
else BrightnessLevel.build(level)
|
|
758
|
+
)
|
|
759
|
+
if (
|
|
760
|
+
brightness == BrightnessLevel.UNKNOWN
|
|
761
|
+
or brightness not in _BRIGHTNESS_TO_WIRE
|
|
762
|
+
):
|
|
763
|
+
msg = f"Invalid logo light brightness level: {level}"
|
|
764
|
+
raise ValueError(msg)
|
|
765
|
+
|
|
766
|
+
wire_val = _BRIGHTNESS_TO_WIRE[brightness]
|
|
767
|
+
await self._send_command({"logoLight": {"control": wire_val}})
|
|
768
|
+
|
|
769
|
+
async def set_energy_saving_schedule( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
770
|
+
self,
|
|
771
|
+
schedule_id: int,
|
|
772
|
+
*,
|
|
773
|
+
enabled: bool,
|
|
774
|
+
start_hour: int,
|
|
775
|
+
start_minute: int,
|
|
776
|
+
duration: int,
|
|
777
|
+
) -> None:
|
|
778
|
+
"""Configure an Energy Saving schedule (schedule 1 or 2).
|
|
779
|
+
|
|
780
|
+
Args:
|
|
781
|
+
----
|
|
782
|
+
schedule_id: Schedule number (1 or 2).
|
|
783
|
+
enabled: Whether the schedule should be active.
|
|
784
|
+
start_hour: Start hour in 24-hour format (0-23).
|
|
785
|
+
start_minute: Start minute (0-59).
|
|
786
|
+
duration: Duration in hours (1-24).
|
|
787
|
+
|
|
788
|
+
Raises:
|
|
789
|
+
------
|
|
790
|
+
ValueError: If schedule_id or time/duration parameters are invalid.
|
|
791
|
+
|
|
792
|
+
"""
|
|
793
|
+
if schedule_id not in VALID_ENERGY_SAVING_SCHEDULE_IDS:
|
|
794
|
+
msg = (
|
|
795
|
+
f"Schedule ID must be one of {VALID_ENERGY_SAVING_SCHEDULE_IDS}, "
|
|
796
|
+
f"got {schedule_id}"
|
|
797
|
+
)
|
|
798
|
+
raise ValueError(msg)
|
|
799
|
+
if not 0 <= start_hour <= 23:
|
|
800
|
+
msg = f"start_hour must be between 0 and 23, got {start_hour}"
|
|
801
|
+
raise ValueError(msg)
|
|
802
|
+
if not 0 <= start_minute <= 59:
|
|
803
|
+
msg = f"start_minute must be between 0 and 59, got {start_minute}"
|
|
804
|
+
raise ValueError(msg)
|
|
805
|
+
if not 1 <= duration <= 24:
|
|
806
|
+
msg = f"duration must be between 1 and 24, got {duration}"
|
|
807
|
+
raise ValueError(msg)
|
|
808
|
+
|
|
809
|
+
# Note: The ESP32 HNA firmware energySavings handler expects startHour,
|
|
810
|
+
# startMinute, and duration formatted as strings in the control object.
|
|
811
|
+
mode_val = "on" if enabled else "off"
|
|
812
|
+
await self._send_command(
|
|
813
|
+
{
|
|
814
|
+
"energySavings": {
|
|
815
|
+
f"energySaving{schedule_id}": {
|
|
816
|
+
"control": {
|
|
817
|
+
"mode": mode_val,
|
|
818
|
+
"startHour": str(start_hour),
|
|
819
|
+
"startMinute": str(start_minute),
|
|
820
|
+
"duration": str(duration),
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
async def set_clean_cycle_schedule(
|
|
828
|
+
self,
|
|
829
|
+
*,
|
|
830
|
+
enabled: bool,
|
|
831
|
+
start_hour: int,
|
|
832
|
+
start_minute: int,
|
|
833
|
+
) -> None:
|
|
834
|
+
"""Configure the recurring clean cycle timer schedule.
|
|
835
|
+
|
|
836
|
+
Args:
|
|
837
|
+
----
|
|
838
|
+
enabled: True to enable 24-hour clean cycle schedule, False to disable.
|
|
839
|
+
start_hour: Start hour in 24-hour format (0-23).
|
|
840
|
+
start_minute: Start minute (0-59).
|
|
841
|
+
|
|
842
|
+
Raises:
|
|
843
|
+
------
|
|
844
|
+
ValueError: If time parameters are out of range.
|
|
845
|
+
|
|
846
|
+
"""
|
|
847
|
+
if not 0 <= start_hour <= 23:
|
|
848
|
+
msg = f"start_hour must be between 0 and 23, got {start_hour}"
|
|
849
|
+
raise ValueError(msg)
|
|
850
|
+
if not 0 <= start_minute <= 59:
|
|
851
|
+
msg = f"start_minute must be between 0 and 59, got {start_minute}"
|
|
852
|
+
raise ValueError(msg)
|
|
853
|
+
|
|
854
|
+
# Note: Unlike energySavings, the ESP32 HNA cleanCycleTimer endpoint
|
|
855
|
+
# parses startHour and startMinute as raw integers.
|
|
856
|
+
clean_timer = "enable" if enabled else "disable"
|
|
857
|
+
await self._send_command(
|
|
858
|
+
{
|
|
859
|
+
"cleanCycleTimer": {
|
|
860
|
+
"cleanTimer": clean_timer,
|
|
861
|
+
"startHour": start_hour,
|
|
862
|
+
"startMinute": start_minute,
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
)
|
|
866
|
+
|
|
630
867
|
async def close(self) -> None:
|
|
631
868
|
"""Close open client session."""
|
|
632
869
|
if self.session and self._close_session:
|