python-hotspring 2.0.0__py3-none-any.whl → 2.1.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 CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  from .const import (
4
4
  BrightnessLevel,
5
+ DeviceType,
5
6
  HeatingMode,
6
7
  JetSpeed,
7
8
  LightColor,
@@ -15,7 +16,9 @@ from .exceptions import (
15
16
  HotSpringConnectionError,
16
17
  HotSpringConnectionTimeoutError,
17
18
  HotSpringError,
19
+ HotSpringInvalidDeviceError,
18
20
  HotSpringNotReadyError,
21
+ HotSpringSNADetectedError,
19
22
  )
20
23
  from .hotspring import HotSpring
21
24
  from .models import (
@@ -41,6 +44,7 @@ __all__ = [
41
44
  "BrightnessLevel",
42
45
  "CleanCycle",
43
46
  "ConnectionStatus",
47
+ "DeviceType",
44
48
  "Diagnostics",
45
49
  "EnergySaving",
46
50
  "FreshWaterIQ",
@@ -51,7 +55,9 @@ __all__ = [
51
55
  "HotSpringConnectionError",
52
56
  "HotSpringConnectionTimeoutError",
53
57
  "HotSpringError",
58
+ "HotSpringInvalidDeviceError",
54
59
  "HotSpringNotReadyError",
60
+ "HotSpringSNADetectedError",
55
61
  "Jet",
56
62
  "JetSpeed",
57
63
  "LightColor",
hotspring/const.py CHANGED
@@ -408,3 +408,36 @@ def resolve_spa_model(
408
408
  model_name = SPA_MODEL_MAP.get((brand_id, collection_id, model_id), "Unknown")
409
409
 
410
410
  return (brand, collection, model_name)
411
+
412
+
413
+ class DeviceType(Enum):
414
+ """Connected Spa kit adapter device type.
415
+
416
+ The Connected Spa Kit 2 consists of two modules:
417
+ - HNA (Home Network Adapter): Connected to home network, acts as API bridge.
418
+ - SNA (Spa Network Adapter): Located in the tub, physically wired to controller.
419
+ """
420
+
421
+ UNKNOWN = "unknown"
422
+ HNA = "hna"
423
+ SNA = "sna"
424
+
425
+ @classmethod
426
+ def build(cls, value: str | None) -> DeviceType:
427
+ """Parse a raw string into a DeviceType.
428
+
429
+ Args:
430
+ ----
431
+ value: The raw device type string, or None.
432
+
433
+ Returns:
434
+ -------
435
+ The matching DeviceType, or DeviceType.UNKNOWN for unrecognized values.
436
+
437
+ """
438
+ if value is None:
439
+ return cls.UNKNOWN
440
+ return _DEVICE_TYPE_MAP.get(value.lower(), cls.UNKNOWN)
441
+
442
+
443
+ _DEVICE_TYPE_MAP: dict[str, DeviceType] = {d.value: d for d in DeviceType}
hotspring/exceptions.py CHANGED
@@ -26,3 +26,18 @@ class HotSpringCommandError(HotSpringError):
26
26
 
27
27
  Raised when a command sent to the spa is rejected or fails.
28
28
  """
29
+
30
+
31
+ class HotSpringInvalidDeviceError(HotSpringError):
32
+ """Hot Spring invalid device exception.
33
+
34
+ Raised when attempting to connect to or command an unsupported device.
35
+ """
36
+
37
+
38
+ class HotSpringSNADetectedError(HotSpringInvalidDeviceError):
39
+ """Hot Spring SNA detected exception.
40
+
41
+ Raised when connecting to a Spa Network Adapter (SNA) instead of
42
+ the Home Network Adapter (HNA). The HNA must be used as the API bridge.
43
+ """
hotspring/hotspring.py CHANGED
@@ -24,6 +24,7 @@ from .exceptions import (
24
24
  HotSpringConnectionTimeoutError,
25
25
  HotSpringError,
26
26
  HotSpringNotReadyError,
27
+ HotSpringSNADetectedError,
27
28
  )
28
29
  from .models import (
29
30
  ConnectionStatus,
@@ -54,6 +55,7 @@ class HotSpring:
54
55
  host: str
55
56
  session: aiohttp.ClientSession | None = None
56
57
  request_timeout: float = 10.0
58
+ validate_device: bool = True
57
59
  _close_session: bool = False
58
60
  _identity_loaded: bool = False
59
61
  spa: Spa | None = None
@@ -166,8 +168,9 @@ class HotSpring:
166
168
  the main /status endpoint concurrently with /startup, /spaConnectStatus,
167
169
  and /spamodel.
168
170
 
169
- On subsequent routine polling cycles, it only queries the fast /status
170
- endpoint, avoiding redundant radio (LoRA) queries for static identity data.
171
+ On subsequent routine polling cycles, it queries /status and /spaConnectStatus
172
+ concurrently, avoiding redundant radio (LoRA) queries for static identity data
173
+ while keeping telemetry and connection status fresh.
171
174
 
172
175
  Args:
173
176
  ----
@@ -180,6 +183,8 @@ class HotSpring:
180
183
 
181
184
  Raises:
182
185
  ------
186
+ HotSpringSNADetectedError: If connected to a Spa Network Adapter (SNA)
187
+ and ``validate_device`` is True.
183
188
  HotSpringError: If no data is returned from the spa.
184
189
 
185
190
  """
@@ -205,18 +210,53 @@ class HotSpring:
205
210
  if connect_res:
206
211
  self.spa.update_connection_status(connect_res)
207
212
 
213
+ if self.validate_device and self.spa.info.is_sna:
214
+ msg = (
215
+ f"Connected to Spa Network Adapter (SNA) with hostname "
216
+ f"'{self.spa.info.hostname}'. The Home Network Adapter (HNA) "
217
+ f"with root topic '{self.spa.info.root_topic}' must be "
218
+ f"used instead."
219
+ )
220
+ raise HotSpringSNADetectedError(msg)
221
+
208
222
  self._identity_loaded = True
209
223
  return self.spa
210
224
 
211
- status_data = await self.request("/status")
225
+ status_res, connect_res = await asyncio.gather(
226
+ self.request("/status"),
227
+ self._safe_request("/spaConnectStatus"),
228
+ )
212
229
 
213
230
  if self.spa is None: # Safety guard; spa is always set after cold sync
214
- self.spa = Spa(status_data)
231
+ self.spa = Spa(status_res)
215
232
  else:
216
- self.spa.update_from_dict(status_data)
233
+ self.spa.update_from_dict(status_res)
234
+
235
+ if connect_res:
236
+ self.spa.update_connection_status(connect_res)
217
237
 
218
238
  return self.spa
219
239
 
240
+ async def get_device_info(self) -> SpaInfo:
241
+ """Fetch lightweight device identity directly from /startup.
242
+
243
+ Useful for discovery and pre-flight device validation without querying
244
+ the spa controller or LoRA radio endpoints.
245
+
246
+ Returns
247
+ -------
248
+ A SpaInfo instance parsed from the /startup response.
249
+
250
+ Raises
251
+ ------
252
+ HotSpringConnectionError: If connection fails.
253
+ HotSpringConnectionTimeoutError: If request times out.
254
+ HotSpringError: If the response is invalid.
255
+
256
+ """
257
+ data = await self.request("/startup")
258
+ return SpaInfo.from_dict(data)
259
+
220
260
  async def update_identity(self) -> SpaInfo:
221
261
  """Fetch and update static spa identity info (/startup and /spamodel).
222
262
 
@@ -226,6 +266,8 @@ class HotSpring:
226
266
 
227
267
  Raises
228
268
  ------
269
+ HotSpringSNADetectedError: If connected to a Spa Network Adapter (SNA)
270
+ and ``validate_device`` is True.
229
271
  HotSpringError: If the spa has not been initialized with update().
230
272
 
231
273
  """
@@ -247,6 +289,15 @@ class HotSpring:
247
289
  if identity_data:
248
290
  self.spa.update_info(identity_data)
249
291
 
292
+ if self.validate_device and self.spa.info.is_sna:
293
+ msg = (
294
+ f"Connected to Spa Network Adapter (SNA) with hostname "
295
+ f"'{self.spa.info.hostname}'. The Home Network Adapter (HNA) "
296
+ f"with root topic '{self.spa.info.root_topic}' must be "
297
+ f"used instead."
298
+ )
299
+ raise HotSpringSNADetectedError(msg)
300
+
250
301
  self._identity_loaded = True
251
302
  return self.spa.info
252
303
 
hotspring/models.py CHANGED
@@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, NamedTuple
13
13
 
14
14
  from .const import (
15
15
  BrightnessLevel,
16
+ DeviceType,
16
17
  HeatingMode,
17
18
  JetSpeed,
18
19
  LightColor,
@@ -302,6 +303,44 @@ class SpaInfo:
302
303
  return ""
303
304
  return ":".join(f"{b:02X}" for b in mac_bytes)
304
305
 
306
+ @property
307
+ def device_type(self) -> DeviceType:
308
+ """Determine whether the device is an HNA or SNA.
309
+
310
+ The HNA (Home Network Adapter) is the intended API bridge.
311
+ The SNA (Spa Network Adapter) is the tub-side module.
312
+
313
+ On the HNA, the hostname suffix (last 6 characters, e.g. from
314
+ 'ConnectedSpa_112233') matches the last 6 characters of the root_topic
315
+ (e.g. 'mySpaAABBCC112233').
316
+ On the SNA, root_topic still points to the paired HNA topic, but
317
+ the hostname reflects the SNA's own MAC address.
318
+
319
+ Returns
320
+ -------
321
+ DeviceType.HNA if hostname matches root_topic,
322
+ DeviceType.SNA if hostname differs from root_topic,
323
+ DeviceType.UNKNOWN if information is missing.
324
+
325
+ """
326
+ if not self.hostname or not self.root_topic:
327
+ return DeviceType.UNKNOWN
328
+
329
+ mac_suffix = self.hostname.rsplit("_", 1)[-1]
330
+ if self.root_topic.lower().endswith(mac_suffix.lower()):
331
+ return DeviceType.HNA
332
+ return DeviceType.SNA
333
+
334
+ @property
335
+ def is_hna(self) -> bool:
336
+ """Return True if this device is the Home Network Adapter (HNA)."""
337
+ return self.device_type == DeviceType.HNA
338
+
339
+ @property
340
+ def is_sna(self) -> bool:
341
+ """Return True if this device is the Spa Network Adapter (SNA)."""
342
+ return self.device_type == DeviceType.SNA
343
+
305
344
 
306
345
  @dataclass
307
346
  class Heater: # pylint: disable=too-many-instance-attributes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hotspring
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Asynchronous Python client for Hot Spring Connected Spa Kit 2.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -0,0 +1,10 @@
1
+ hotspring/__init__.py,sha256=hZfT9Trw36AM_NzVGHp4ABNCcWmVvFkw6NZMHIqSUu4,1440
2
+ hotspring/const.py,sha256=jEreZ0L9m4RJ3RxmSvdx_FmSrYfLK62CjBaLU84_1WU,12664
3
+ hotspring/exceptions.py,sha256=R8YwGfi6mXZ5DcHw4_fmtrRuvgkfOrLRCH-d3x6OUIw,1184
4
+ hotspring/hotspring.py,sha256=qwYPshbEEzuBF-EzbbYJoY-edSIKzMz-E19vuqeT0cY,20076
5
+ hotspring/models.py,sha256=yNxkrbAWzl6nNpnMe32f8GVWHdI7yA6urVPk3tx-r-g,45567
6
+ hotspring/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ python_hotspring-2.1.0.dist-info/METADATA,sha256=VJZCT83qAHrsXvaLw39y7LwpUndBioX_v-Fkl4DrNDc,8056
8
+ python_hotspring-2.1.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
9
+ python_hotspring-2.1.0.dist-info/licenses/LICENSE,sha256=4n3vNP1aJ5r8xLfzCCjo5l0EK5-k2-7w9rgucn7WVFM,1080
10
+ python_hotspring-2.1.0.dist-info/RECORD,,
@@ -1,10 +0,0 @@
1
- hotspring/__init__.py,sha256=KCAj9NMnkDpHCAEk2u1JCY7vgsAFSpHZNhXvvemscwM,1274
2
- hotspring/const.py,sha256=J9c7nNkUexk6wmXfDaK8Mnr4va61CiKtHjP2tjVfb40,11774
3
- hotspring/exceptions.py,sha256=thhwkqpceaja_5i7gY_r1P13XkTYi3Vc99p-LwxjwcI,743
4
- hotspring/hotspring.py,sha256=evs5qAIJwLUhiW00KGg5JqMu7TbrO2fIibuN6325x7U,17998
5
- hotspring/models.py,sha256=qldCRZB3BGIJUuTteLYcNNSLferKw7T9uQe_9JhWFTI,44148
6
- hotspring/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- python_hotspring-2.0.0.dist-info/METADATA,sha256=AQF5MiPET3Uz-wcK5U-mB7WP-ZRn7qCypmq_kuH7xlU,8056
8
- python_hotspring-2.0.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
9
- python_hotspring-2.0.0.dist-info/licenses/LICENSE,sha256=4n3vNP1aJ5r8xLfzCCjo5l0EK5-k2-7w9rgucn7WVFM,1080
10
- python_hotspring-2.0.0.dist-info/RECORD,,