python-hotspring 2.0.1__tar.gz → 2.1.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hotspring
3
- Version: 2.0.1
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
@@ -22,7 +22,7 @@ packages = [
22
22
  ]
23
23
  readme = "README.md"
24
24
  repository = "https://github.com/Moustachauve/python-hotspring"
25
- version = "2.0.1"
25
+ version = "2.1.0"
26
26
 
27
27
  [tool.poetry.dependencies]
28
28
  aiohttp = ">=3.0.0"
@@ -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",
@@ -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}
@@ -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
+ """
@@ -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
@@ -181,6 +183,8 @@ class HotSpring:
181
183
 
182
184
  Raises:
183
185
  ------
186
+ HotSpringSNADetectedError: If connected to a Spa Network Adapter (SNA)
187
+ and ``validate_device`` is True.
184
188
  HotSpringError: If no data is returned from the spa.
185
189
 
186
190
  """
@@ -206,6 +210,15 @@ class HotSpring:
206
210
  if connect_res:
207
211
  self.spa.update_connection_status(connect_res)
208
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
+
209
222
  self._identity_loaded = True
210
223
  return self.spa
211
224
 
@@ -224,6 +237,26 @@ class HotSpring:
224
237
 
225
238
  return self.spa
226
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
+
227
260
  async def update_identity(self) -> SpaInfo:
228
261
  """Fetch and update static spa identity info (/startup and /spamodel).
229
262
 
@@ -233,6 +266,8 @@ class HotSpring:
233
266
 
234
267
  Raises
235
268
  ------
269
+ HotSpringSNADetectedError: If connected to a Spa Network Adapter (SNA)
270
+ and ``validate_device`` is True.
236
271
  HotSpringError: If the spa has not been initialized with update().
237
272
 
238
273
  """
@@ -254,6 +289,15 @@ class HotSpring:
254
289
  if identity_data:
255
290
  self.spa.update_info(identity_data)
256
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
+
257
301
  self._identity_loaded = True
258
302
  return self.spa.info
259
303
 
@@ -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