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/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()