python-hotspring 1.0.0__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 +67 -0
- hotspring/const.py +237 -0
- hotspring/exceptions.py +28 -0
- hotspring/hotspring.py +487 -0
- hotspring/models.py +762 -0
- hotspring/py.typed +0 -0
- python_hotspring-1.0.0.dist-info/METADATA +208 -0
- python_hotspring-1.0.0.dist-info/RECORD +10 -0
- python_hotspring-1.0.0.dist-info/WHEEL +4 -0
- python_hotspring-1.0.0.dist-info/licenses/LICENSE +21 -0
hotspring/hotspring.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""Asynchronous Python client for Hot Spring Connected Spa Kit 2."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Self
|
|
9
|
+
|
|
10
|
+
import aiohttp
|
|
11
|
+
import backoff
|
|
12
|
+
from yarl import URL
|
|
13
|
+
|
|
14
|
+
from .exceptions import (
|
|
15
|
+
HotSpringCommandError,
|
|
16
|
+
HotSpringConnectionError,
|
|
17
|
+
HotSpringConnectionTimeoutError,
|
|
18
|
+
HotSpringError,
|
|
19
|
+
HotSpringNotReadyError,
|
|
20
|
+
)
|
|
21
|
+
from .models import (
|
|
22
|
+
ConnectionStatus,
|
|
23
|
+
Diagnostics,
|
|
24
|
+
FreshWaterIQ,
|
|
25
|
+
Spa,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class HotSpring:
|
|
31
|
+
"""Main class for handling connections with a Hot Spring Spa.
|
|
32
|
+
|
|
33
|
+
The Hot Spring Connected Spa Kit 2 uses a Home Network Adapter (HNA)
|
|
34
|
+
that runs a local HTTP API. This client communicates with the HNA
|
|
35
|
+
to poll spa state and send control commands.
|
|
36
|
+
|
|
37
|
+
Usage::
|
|
38
|
+
|
|
39
|
+
async with HotSpring("192.168.1.100") as spa_client:
|
|
40
|
+
spa = await spa_client.update()
|
|
41
|
+
print(spa.heater.current_temperature)
|
|
42
|
+
await spa_client.set_temperature(102)
|
|
43
|
+
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
host: str
|
|
47
|
+
session: aiohttp.ClientSession | None = None
|
|
48
|
+
request_timeout: float = 10.0
|
|
49
|
+
_close_session: bool = False
|
|
50
|
+
spa: Spa | None = None
|
|
51
|
+
|
|
52
|
+
@backoff.on_exception(
|
|
53
|
+
backoff.expo,
|
|
54
|
+
HotSpringConnectionError,
|
|
55
|
+
max_tries=3,
|
|
56
|
+
logger=None,
|
|
57
|
+
)
|
|
58
|
+
async def request(
|
|
59
|
+
self,
|
|
60
|
+
uri: str = "",
|
|
61
|
+
method: str = "GET",
|
|
62
|
+
data: dict[str, object] | None = None,
|
|
63
|
+
) -> dict[str, object]:
|
|
64
|
+
"""Handle a request to the Hot Spring HNA.
|
|
65
|
+
|
|
66
|
+
A generic method for sending/handling HTTP requests done against
|
|
67
|
+
the Hot Spring Home Network Adapter.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
----
|
|
71
|
+
uri: Request URI, for example ``/status``.
|
|
72
|
+
method: HTTP method to use for the request.
|
|
73
|
+
data: Dictionary of data to send to the HNA.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
-------
|
|
77
|
+
A Python dictionary (JSON decoded) with the response from the
|
|
78
|
+
Hot Spring HNA.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
------
|
|
82
|
+
HotSpringConnectionError: An error occurred while communicating
|
|
83
|
+
with the Hot Spring HNA.
|
|
84
|
+
HotSpringConnectionTimeoutError: A timeout occurred while
|
|
85
|
+
communicating with the Hot Spring HNA.
|
|
86
|
+
HotSpringError: Received an unexpected response from the HNA.
|
|
87
|
+
|
|
88
|
+
"""
|
|
89
|
+
url = URL.build(scheme="http", host=self.host, port=80, path=uri)
|
|
90
|
+
|
|
91
|
+
headers = {
|
|
92
|
+
"Accept": "application/json, text/plain, */*",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if self.session is None:
|
|
96
|
+
self.session = aiohttp.ClientSession()
|
|
97
|
+
self._close_session = True
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
async with asyncio.timeout(self.request_timeout):
|
|
101
|
+
response = await self.session.request(
|
|
102
|
+
method,
|
|
103
|
+
url,
|
|
104
|
+
json=data,
|
|
105
|
+
headers=headers,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if response.status // 100 in [4, 5]:
|
|
109
|
+
contents = await response.read()
|
|
110
|
+
response.close()
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
raise HotSpringError(
|
|
114
|
+
response.status,
|
|
115
|
+
json.loads(contents.decode("utf8")),
|
|
116
|
+
)
|
|
117
|
+
except json.JSONDecodeError:
|
|
118
|
+
raise HotSpringError(
|
|
119
|
+
response.status,
|
|
120
|
+
{"message": contents.decode("utf8")},
|
|
121
|
+
) from None
|
|
122
|
+
|
|
123
|
+
# The spa returns JSON with text/html Content-Type,
|
|
124
|
+
# so always try JSON parsing first.
|
|
125
|
+
body = await response.text()
|
|
126
|
+
try:
|
|
127
|
+
response_data = json.loads(body)
|
|
128
|
+
except json.JSONDecodeError as exc:
|
|
129
|
+
msg = f"Invalid JSON response from {uri}: {body[:200]}"
|
|
130
|
+
raise HotSpringError(msg) from exc
|
|
131
|
+
|
|
132
|
+
except asyncio.TimeoutError as exception:
|
|
133
|
+
msg = f"Timeout occurred while connecting to Hot Spring HNA at {self.host}"
|
|
134
|
+
raise HotSpringConnectionTimeoutError(msg) from exception
|
|
135
|
+
except aiohttp.ClientError as exception:
|
|
136
|
+
msg = (
|
|
137
|
+
f"Error occurred while communicating with Hot Spring HNA at {self.host}"
|
|
138
|
+
)
|
|
139
|
+
raise HotSpringConnectionError(msg) from exception
|
|
140
|
+
|
|
141
|
+
if not isinstance(response_data, dict):
|
|
142
|
+
msg = f"Unexpected response type from {uri}"
|
|
143
|
+
raise HotSpringError(msg)
|
|
144
|
+
|
|
145
|
+
return response_data
|
|
146
|
+
|
|
147
|
+
async def update(self) -> Spa:
|
|
148
|
+
"""Get all spa information in a single polling cycle.
|
|
149
|
+
|
|
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.
|
|
153
|
+
|
|
154
|
+
Returns
|
|
155
|
+
-------
|
|
156
|
+
The updated Spa data object.
|
|
157
|
+
|
|
158
|
+
Raises
|
|
159
|
+
------
|
|
160
|
+
HotSpringError: If no data is returned from the spa.
|
|
161
|
+
|
|
162
|
+
"""
|
|
163
|
+
# Fetch main status
|
|
164
|
+
status_data = await self.request("/status")
|
|
165
|
+
|
|
166
|
+
if self.spa is None:
|
|
167
|
+
self.spa = Spa(status_data)
|
|
168
|
+
else:
|
|
169
|
+
self.spa.update_from_dict(status_data)
|
|
170
|
+
|
|
171
|
+
# Fetch identity/startup info
|
|
172
|
+
try:
|
|
173
|
+
startup_data = await self.request("/startup")
|
|
174
|
+
self.spa.update_info(startup_data)
|
|
175
|
+
except HotSpringError:
|
|
176
|
+
pass # Non-critical; identity may already be populated
|
|
177
|
+
|
|
178
|
+
# Fetch connection status
|
|
179
|
+
try:
|
|
180
|
+
connect_data = await self.request("/spaConnectStatus")
|
|
181
|
+
self.spa.update_connection_status(connect_data)
|
|
182
|
+
except HotSpringError:
|
|
183
|
+
pass # Non-critical
|
|
184
|
+
|
|
185
|
+
return self.spa
|
|
186
|
+
|
|
187
|
+
async def update_water_care(self) -> FreshWaterIQ:
|
|
188
|
+
"""Update FreshWater IQ water quality data.
|
|
189
|
+
|
|
190
|
+
This polls the /getFWIQData endpoint. Recommended polling interval
|
|
191
|
+
is 60 seconds (slower than the main status poll).
|
|
192
|
+
|
|
193
|
+
Returns
|
|
194
|
+
-------
|
|
195
|
+
The updated FreshWaterIQ data.
|
|
196
|
+
|
|
197
|
+
Raises
|
|
198
|
+
------
|
|
199
|
+
HotSpringError: If no data is returned.
|
|
200
|
+
|
|
201
|
+
"""
|
|
202
|
+
data = await self.request("/getFWIQData")
|
|
203
|
+
|
|
204
|
+
if self.spa is None:
|
|
205
|
+
msg = "Call update() before update_water_care()"
|
|
206
|
+
raise HotSpringError(msg)
|
|
207
|
+
|
|
208
|
+
self.spa.update_freshwater_iq(data)
|
|
209
|
+
return self.spa.freshwater_iq
|
|
210
|
+
|
|
211
|
+
async def update_diagnostics(self) -> Diagnostics:
|
|
212
|
+
"""Update diagnostic and power metrics.
|
|
213
|
+
|
|
214
|
+
Fetches the /addDebugData endpoint. Availability depends on the
|
|
215
|
+
spa model and sensor configuration.
|
|
216
|
+
|
|
217
|
+
Returns
|
|
218
|
+
-------
|
|
219
|
+
The updated Diagnostics data.
|
|
220
|
+
|
|
221
|
+
Raises
|
|
222
|
+
------
|
|
223
|
+
HotSpringError: If no data is returned.
|
|
224
|
+
|
|
225
|
+
"""
|
|
226
|
+
data = await self.request("/addDebugData")
|
|
227
|
+
|
|
228
|
+
if self.spa is None:
|
|
229
|
+
msg = "Call update() before update_diagnostics()"
|
|
230
|
+
raise HotSpringError(msg)
|
|
231
|
+
|
|
232
|
+
self.spa.update_diagnostics(data)
|
|
233
|
+
return self.spa.diagnostics
|
|
234
|
+
|
|
235
|
+
async def update_connection_status(self) -> ConnectionStatus:
|
|
236
|
+
"""Update HNA/SNA/cloud connection status.
|
|
237
|
+
|
|
238
|
+
Returns
|
|
239
|
+
-------
|
|
240
|
+
The updated ConnectionStatus data.
|
|
241
|
+
|
|
242
|
+
Raises
|
|
243
|
+
------
|
|
244
|
+
HotSpringError: If no data is returned.
|
|
245
|
+
|
|
246
|
+
"""
|
|
247
|
+
data = await self.request("/spaConnectStatus")
|
|
248
|
+
|
|
249
|
+
if self.spa is None:
|
|
250
|
+
msg = "Call update() before update_connection_status()"
|
|
251
|
+
raise HotSpringError(msg)
|
|
252
|
+
|
|
253
|
+
self.spa.update_connection_status(data)
|
|
254
|
+
return self.spa.connection_status
|
|
255
|
+
|
|
256
|
+
async def _send_command(self, payload: dict[str, object]) -> None:
|
|
257
|
+
"""Send a control command to the spa via POST /spaManager.
|
|
258
|
+
|
|
259
|
+
All control commands are sent as JSON payloads to
|
|
260
|
+
the ``/spaManager`` endpoint on the HNA.
|
|
261
|
+
|
|
262
|
+
.. note::
|
|
263
|
+
|
|
264
|
+
The firmware requires deeply nested payload structures (e.g.,
|
|
265
|
+
multiple `control` keys) for most commands.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
----
|
|
269
|
+
payload: Flat key-value command payload.
|
|
270
|
+
|
|
271
|
+
Raises:
|
|
272
|
+
------
|
|
273
|
+
HotSpringNotReadyError: If the SNA bridge is not connected.
|
|
274
|
+
HotSpringCommandError: If the command fails.
|
|
275
|
+
|
|
276
|
+
"""
|
|
277
|
+
if self.spa is not None and not self.spa.connection_status.spa_connected:
|
|
278
|
+
msg = (
|
|
279
|
+
"Cannot send commands: SNA bridge is not connected. "
|
|
280
|
+
"The LoRA link between the HNA and the spa is down."
|
|
281
|
+
)
|
|
282
|
+
raise HotSpringNotReadyError(msg)
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
await self.request("/spaManager", method="POST", data=payload)
|
|
286
|
+
except HotSpringError as exception:
|
|
287
|
+
msg = f"Command failed: {payload}"
|
|
288
|
+
raise HotSpringCommandError(msg) from exception
|
|
289
|
+
|
|
290
|
+
async def set_temperature(self, temperature: int) -> None:
|
|
291
|
+
"""Set the target water temperature.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
----
|
|
295
|
+
temperature: Target temperature in the spa's configured unit
|
|
296
|
+
(Fahrenheit or Celsius).
|
|
297
|
+
|
|
298
|
+
"""
|
|
299
|
+
await self._send_command(
|
|
300
|
+
{"heater": {"control": {"temperatureABS": str(temperature)}}}
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
async def set_heating_mode(self, mode: str) -> None:
|
|
304
|
+
"""Set the heating mode.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
----
|
|
308
|
+
mode: The heating mode value. Use HeatingMode enum values,
|
|
309
|
+
e.g. ``HeatingMode.HEAT_WITH_BOOST.value``.
|
|
310
|
+
|
|
311
|
+
"""
|
|
312
|
+
await self._send_command({"heater": {"control": {"heatingMode": mode}}})
|
|
313
|
+
|
|
314
|
+
async def set_jet(self, jet: int, speed: str) -> None:
|
|
315
|
+
"""Set the speed of a jet pump.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
----
|
|
319
|
+
jet: The jet number (1-based).
|
|
320
|
+
speed: The speed value. Use JetSpeed enum values,
|
|
321
|
+
e.g. ``JetSpeed.HIGH_SPEED.value``.
|
|
322
|
+
|
|
323
|
+
"""
|
|
324
|
+
await self._send_command({"JET": {f"JET{jet}": {"control": speed}}})
|
|
325
|
+
|
|
326
|
+
async def set_light_color(
|
|
327
|
+
self,
|
|
328
|
+
zone: int,
|
|
329
|
+
color: str,
|
|
330
|
+
intensity: int = 5,
|
|
331
|
+
light_wheel: str = "off",
|
|
332
|
+
) -> None:
|
|
333
|
+
"""Set the color of a light zone.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
----
|
|
337
|
+
zone: The light zone number (1-based).
|
|
338
|
+
color: The color value. Use LightColor enum values,
|
|
339
|
+
e.g. ``LightColor.BLUE.value``.
|
|
340
|
+
intensity: The brightness intensity (0-5). Defaults to 5.
|
|
341
|
+
light_wheel: The light wheel mode. Use LightWheelMode enum values,
|
|
342
|
+
e.g. ``LightWheelMode.OFF.value``. Defaults to "off".
|
|
343
|
+
|
|
344
|
+
"""
|
|
345
|
+
await self._send_command(
|
|
346
|
+
{
|
|
347
|
+
"lights": {
|
|
348
|
+
"control": {
|
|
349
|
+
f"Zone{zone}": {
|
|
350
|
+
"control": {
|
|
351
|
+
"color": color.upper(),
|
|
352
|
+
"IntensityAbs": intensity,
|
|
353
|
+
"lightWheel": light_wheel,
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
async def turn_off_light(self, zone: int) -> None:
|
|
362
|
+
"""Turn off a light zone.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
----
|
|
366
|
+
zone: The light zone number (1-based).
|
|
367
|
+
|
|
368
|
+
"""
|
|
369
|
+
await self._send_command(
|
|
370
|
+
{
|
|
371
|
+
"lights": {
|
|
372
|
+
"control": {
|
|
373
|
+
f"Zone{zone}": {
|
|
374
|
+
"control": {
|
|
375
|
+
"Intensity": "off",
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
async def set_light_brightness(self, zone: int, *, full: bool = True) -> None:
|
|
384
|
+
"""Set the brightness of a light zone.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
----
|
|
388
|
+
zone: The light zone number (1-based).
|
|
389
|
+
full: True to set to full brightness ("fullon"), False to turn off ("off").
|
|
390
|
+
|
|
391
|
+
"""
|
|
392
|
+
intensity = "fullon" if full else "off"
|
|
393
|
+
await self._send_command(
|
|
394
|
+
{
|
|
395
|
+
"lights": {
|
|
396
|
+
"control": {
|
|
397
|
+
f"Zone{zone}": {
|
|
398
|
+
"control": {
|
|
399
|
+
"Intensity": intensity,
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
async def set_light_rgb(
|
|
408
|
+
self,
|
|
409
|
+
zone: int,
|
|
410
|
+
red: int,
|
|
411
|
+
green: int,
|
|
412
|
+
blue: int,
|
|
413
|
+
) -> None:
|
|
414
|
+
"""Set the exact RGB color of a light zone.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
----
|
|
418
|
+
zone: The light zone number (1-based).
|
|
419
|
+
red: Red value (0-255).
|
|
420
|
+
green: Green value (0-255).
|
|
421
|
+
blue: Blue value (0-255).
|
|
422
|
+
|
|
423
|
+
"""
|
|
424
|
+
await self._send_command(
|
|
425
|
+
{
|
|
426
|
+
"lights": {
|
|
427
|
+
"control": {
|
|
428
|
+
f"Zone{zone}": {
|
|
429
|
+
"control": {
|
|
430
|
+
"rgbFactor": {
|
|
431
|
+
"red": str(red),
|
|
432
|
+
"green": str(green),
|
|
433
|
+
"blue": str(blue),
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
async def set_clean_cycle(self, *, enabled: bool) -> None:
|
|
443
|
+
"""Enable or disable the clean cycle.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
----
|
|
447
|
+
enabled: True to enable, False to disable.
|
|
448
|
+
|
|
449
|
+
"""
|
|
450
|
+
value = "on" if enabled else "off"
|
|
451
|
+
await self._send_command({"cleanCycle": {"control": {"cleanCycle": value}}})
|
|
452
|
+
|
|
453
|
+
async def set_blower(self, *, on: bool) -> None:
|
|
454
|
+
"""Turn the blower on or off.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
----
|
|
458
|
+
on: True to turn on, False to turn off.
|
|
459
|
+
|
|
460
|
+
"""
|
|
461
|
+
value = "on" if on else "off"
|
|
462
|
+
await self._send_command({"blower": {"control": value}})
|
|
463
|
+
|
|
464
|
+
async def close(self) -> None:
|
|
465
|
+
"""Close open client session."""
|
|
466
|
+
if self.session and self._close_session:
|
|
467
|
+
await self.session.close()
|
|
468
|
+
|
|
469
|
+
async def __aenter__(self) -> Self:
|
|
470
|
+
"""Async enter.
|
|
471
|
+
|
|
472
|
+
Returns
|
|
473
|
+
-------
|
|
474
|
+
The HotSpring object.
|
|
475
|
+
|
|
476
|
+
"""
|
|
477
|
+
return self
|
|
478
|
+
|
|
479
|
+
async def __aexit__(self, *_exc_info: object) -> None:
|
|
480
|
+
"""Async exit.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
----
|
|
484
|
+
_exc_info: Exec type.
|
|
485
|
+
|
|
486
|
+
"""
|
|
487
|
+
await self.close()
|