python-hotspring 2.0.0__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.0 → python_hotspring-2.1.0}/PKG-INFO +1 -1
- {python_hotspring-2.0.0 → python_hotspring-2.1.0}/pyproject.toml +1 -1
- {python_hotspring-2.0.0 → python_hotspring-2.1.0}/src/hotspring/__init__.py +6 -0
- {python_hotspring-2.0.0 → python_hotspring-2.1.0}/src/hotspring/const.py +33 -0
- {python_hotspring-2.0.0 → python_hotspring-2.1.0}/src/hotspring/exceptions.py +15 -0
- {python_hotspring-2.0.0 → python_hotspring-2.1.0}/src/hotspring/hotspring.py +56 -5
- {python_hotspring-2.0.0 → python_hotspring-2.1.0}/src/hotspring/models.py +39 -0
- {python_hotspring-2.0.0 → python_hotspring-2.1.0}/LICENSE +0 -0
- {python_hotspring-2.0.0 → python_hotspring-2.1.0}/README.md +0 -0
- {python_hotspring-2.0.0 → 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
|
|
@@ -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
|
|
170
|
-
|
|
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
|
-
|
|
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(
|
|
231
|
+
self.spa = Spa(status_res)
|
|
215
232
|
else:
|
|
216
|
-
self.spa.update_from_dict(
|
|
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
|
|
|
@@ -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
|