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 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
- return _BRIGHTNESS_MAP.get(value, cls.UNKNOWN)
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
- 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
- )
118
-
119
- if response.status // 100 in [4, 5]:
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
- raise HotSpringError(
125
- response.status,
126
- json.loads(contents.decode("utf8")),
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
- 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()
137
- try:
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
- 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 concurrently with /startup, /spaConnectStatus,
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
- concurrently, avoiding redundant radio (LoRA) queries for static identity data
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, startup_res, connect_res, model_res = await asyncio.gather(
193
- self.request("/status"),
194
- self._safe_request("/startup"),
195
- self._safe_request("/spaConnectStatus"),
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, connect_res = await asyncio.gather(
226
- self.request("/status"),
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, model_res = await asyncio.gather(
279
- self._safe_request("/startup"),
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: int) -> None:
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: