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.
- {python_hotspring-2.0.1 → python_hotspring-2.1.0}/PKG-INFO +1 -1
- {python_hotspring-2.0.1 → python_hotspring-2.1.0}/pyproject.toml +1 -1
- {python_hotspring-2.0.1 → python_hotspring-2.1.0}/src/hotspring/__init__.py +6 -0
- {python_hotspring-2.0.1 → python_hotspring-2.1.0}/src/hotspring/const.py +33 -0
- {python_hotspring-2.0.1 → python_hotspring-2.1.0}/src/hotspring/exceptions.py +15 -0
- {python_hotspring-2.0.1 → python_hotspring-2.1.0}/src/hotspring/hotspring.py +44 -0
- {python_hotspring-2.0.1 → python_hotspring-2.1.0}/src/hotspring/models.py +39 -0
- {python_hotspring-2.0.1 → python_hotspring-2.1.0}/LICENSE +0 -0
- {python_hotspring-2.0.1 → python_hotspring-2.1.0}/README.md +0 -0
- {python_hotspring-2.0.1 → python_hotspring-2.1.0}/src/hotspring/py.typed +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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|