python-hotspring 1.1.0__tar.gz → 1.3.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-1.1.0 → python_hotspring-1.3.0}/PKG-INFO +1 -1
- {python_hotspring-1.1.0 → python_hotspring-1.3.0}/pyproject.toml +1 -1
- {python_hotspring-1.1.0 → python_hotspring-1.3.0}/src/hotspring/__init__.py +2 -0
- {python_hotspring-1.1.0 → python_hotspring-1.3.0}/src/hotspring/const.py +174 -0
- {python_hotspring-1.1.0 → python_hotspring-1.3.0}/src/hotspring/models.py +68 -8
- {python_hotspring-1.1.0 → python_hotspring-1.3.0}/LICENSE +0 -0
- {python_hotspring-1.1.0 → python_hotspring-1.3.0}/README.md +0 -0
- {python_hotspring-1.1.0 → python_hotspring-1.3.0}/src/hotspring/exceptions.py +0 -0
- {python_hotspring-1.1.0 → python_hotspring-1.3.0}/src/hotspring/hotspring.py +0 -0
- {python_hotspring-1.1.0 → python_hotspring-1.3.0}/src/hotspring/py.typed +0 -0
|
@@ -6,6 +6,7 @@ from .const import (
|
|
|
6
6
|
JetSpeed,
|
|
7
7
|
LightColor,
|
|
8
8
|
LightWheelMode,
|
|
9
|
+
SpaBrand,
|
|
9
10
|
SpaFailureState,
|
|
10
11
|
TemperatureUnit,
|
|
11
12
|
)
|
|
@@ -58,6 +59,7 @@ __all__ = [
|
|
|
58
59
|
"LightZone",
|
|
59
60
|
"LogoLight",
|
|
60
61
|
"Spa",
|
|
62
|
+
"SpaBrand",
|
|
61
63
|
"SpaFailureState",
|
|
62
64
|
"SpaInfo",
|
|
63
65
|
"SpaLock",
|
|
@@ -235,3 +235,177 @@ class SpaFailureState(Enum):
|
|
|
235
235
|
|
|
236
236
|
|
|
237
237
|
_FAILURE_STATE_MAP: dict[str, SpaFailureState] = {s.value: s for s in SpaFailureState}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class SpaBrand(Enum):
|
|
241
|
+
"""Brand of the spa (e.g. HotSpring, Caldera)."""
|
|
242
|
+
|
|
243
|
+
UNKNOWN = "Unknown"
|
|
244
|
+
HOTSPRING = "HotSpring"
|
|
245
|
+
CALDERA = "Caldera"
|
|
246
|
+
|
|
247
|
+
@classmethod
|
|
248
|
+
def build(cls, value: str | int | None) -> SpaBrand:
|
|
249
|
+
"""Parse a raw API string or integer into a SpaBrand.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
----
|
|
253
|
+
value: The raw brand ID from the API, or None.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
-------
|
|
257
|
+
The matching SpaBrand enum.
|
|
258
|
+
|
|
259
|
+
"""
|
|
260
|
+
if value is None:
|
|
261
|
+
return cls.UNKNOWN
|
|
262
|
+
try:
|
|
263
|
+
val_int = int(str(value).strip())
|
|
264
|
+
except ValueError:
|
|
265
|
+
return cls.UNKNOWN
|
|
266
|
+
|
|
267
|
+
if val_int == 0:
|
|
268
|
+
return cls.HOTSPRING
|
|
269
|
+
if val_int == 1:
|
|
270
|
+
return cls.CALDERA
|
|
271
|
+
return cls.UNKNOWN
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
SPA_COLLECTION_MAP: dict[tuple[int, int], str] = {
|
|
275
|
+
# HotSpring (Brand 0)
|
|
276
|
+
(0, 0): "HighLife",
|
|
277
|
+
(0, 1): "Limelight",
|
|
278
|
+
(0, 2): "Hot Spot",
|
|
279
|
+
# Caldera (Brand 1)
|
|
280
|
+
(1, 1): "Utopia",
|
|
281
|
+
(1, 3): "Paradise",
|
|
282
|
+
(1, 4): "Vacanza",
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
SPA_MODEL_MAP: dict[tuple[int, int, int], str] = {
|
|
286
|
+
# Brand 0: HotSpring | Collection 0: HighLife
|
|
287
|
+
(0, 0, 0): "HotSpring HighLife",
|
|
288
|
+
(0, 0, 1): "HighLife Jetsetter",
|
|
289
|
+
(0, 0, 2): "HighLife Jetsetter Canada",
|
|
290
|
+
(0, 0, 3): "HighLife Jetsetter LX",
|
|
291
|
+
(0, 0, 4): "HighLife Prodigy",
|
|
292
|
+
(0, 0, 5): "HighLife Sovereign",
|
|
293
|
+
(0, 0, 6): "HighLife Aria",
|
|
294
|
+
(0, 0, 7): "HighLife Envoy",
|
|
295
|
+
(0, 0, 8): "HighLife Vanguard",
|
|
296
|
+
(0, 0, 9): "HighLife Grandee",
|
|
297
|
+
(0, 0, 10): "HighLife Jetsetter International",
|
|
298
|
+
(0, 0, 11): "HighLife Jetsetter LX International",
|
|
299
|
+
(0, 0, 12): "HighLife Prodigy International",
|
|
300
|
+
(0, 0, 13): "HighLife Sovereign International",
|
|
301
|
+
(0, 0, 14): "HighLife Aria International",
|
|
302
|
+
(0, 0, 15): "HighLife Envoy International",
|
|
303
|
+
(0, 0, 16): "HighLife Vanguard International",
|
|
304
|
+
(0, 0, 17): "HighLife Grandee International",
|
|
305
|
+
# Brand 0: HotSpring | Collection 1: Limelight
|
|
306
|
+
(0, 1, 0): "HotSpring Limelight",
|
|
307
|
+
(0, 1, 1): "Limelight Beam",
|
|
308
|
+
(0, 1, 2): "Limelight Beam II",
|
|
309
|
+
(0, 1, 3): "Limelight Beam International",
|
|
310
|
+
(0, 1, 4): "Limelight Beam Canada",
|
|
311
|
+
(0, 1, 5): "Limelight Strobe",
|
|
312
|
+
(0, 1, 6): "Limelight Strobe International",
|
|
313
|
+
(0, 1, 7): "Limelight Flair",
|
|
314
|
+
(0, 1, 8): "Limelight Flair International",
|
|
315
|
+
(0, 1, 9): "Limelight Flash",
|
|
316
|
+
(0, 1, 10): "Limelight Flash International",
|
|
317
|
+
(0, 1, 11): "Limelight Pulse",
|
|
318
|
+
(0, 1, 12): "Limelight Pulse International",
|
|
319
|
+
(0, 1, 13): "Limelight Prism",
|
|
320
|
+
(0, 1, 14): "Limelight Prism International",
|
|
321
|
+
# Brand 0: HotSpring | Collection 2: Hot Spot
|
|
322
|
+
(0, 2, 0): "Hot Spot Sx",
|
|
323
|
+
(0, 2, 1): "Hot Spot Tx",
|
|
324
|
+
(0, 2, 2): "Hot Spot Pace",
|
|
325
|
+
(0, 2, 3): "Hot Spot Stride",
|
|
326
|
+
(0, 2, 4): "Hot Spot Relay",
|
|
327
|
+
(0, 2, 5): "Hot Spot Rhythm",
|
|
328
|
+
(0, 2, 6): "Hot Spot Sx",
|
|
329
|
+
(0, 2, 7): "Hot Spot Tx",
|
|
330
|
+
(0, 2, 8): "Hot Spot Propel",
|
|
331
|
+
(0, 2, 9): "Hot Spot Stride",
|
|
332
|
+
(0, 2, 10): "Hot Spot Relay",
|
|
333
|
+
(0, 2, 11): "Hot Spot Rhythm",
|
|
334
|
+
# Brand 1: Caldera | Collection 1: Utopia
|
|
335
|
+
(1, 1, 0): "Caldera Utopia",
|
|
336
|
+
(1, 1, 1): "Utopia Ravello International",
|
|
337
|
+
(1, 1, 2): "Utopia Niagara International",
|
|
338
|
+
(1, 1, 3): "Utopia Tahitian International",
|
|
339
|
+
(1, 1, 4): "Utopia Florence International",
|
|
340
|
+
(1, 1, 5): "Utopia Geneva International",
|
|
341
|
+
(1, 1, 6): "Utopia Cantabria International",
|
|
342
|
+
(1, 1, 7): "Utopia Ravello",
|
|
343
|
+
(1, 1, 8): "Utopia Niagara",
|
|
344
|
+
(1, 1, 9): "Utopia Tahitian",
|
|
345
|
+
(1, 1, 10): "Utopia Florence",
|
|
346
|
+
(1, 1, 11): "Utopia Geneva",
|
|
347
|
+
(1, 1, 12): "Utopia Cantabria",
|
|
348
|
+
# Brand 1: Caldera | Collection 3: Paradise
|
|
349
|
+
(1, 3, 0): "Caldera Paradise",
|
|
350
|
+
(1, 3, 1): "Paradise Kauai",
|
|
351
|
+
(1, 3, 2): "Paradise Kauai International",
|
|
352
|
+
(1, 3, 3): "Paradise Martinique",
|
|
353
|
+
(1, 3, 4): "Paradise Martinique International",
|
|
354
|
+
(1, 3, 5): "Paradise Makena",
|
|
355
|
+
(1, 3, 6): "Paradise Makena International",
|
|
356
|
+
(1, 3, 7): "Paradise Salina",
|
|
357
|
+
(1, 3, 8): "Paradise Salina International",
|
|
358
|
+
(1, 3, 9): "Paradise Reunion",
|
|
359
|
+
(1, 3, 10): "Paradise Reunion International",
|
|
360
|
+
(1, 3, 11): "Paradise Seychelles",
|
|
361
|
+
(1, 3, 12): "Paradise Seychelles International",
|
|
362
|
+
# Brand 1: Caldera | Collection 4: Vacanza
|
|
363
|
+
(1, 4, 0): "Vacanza Aventine",
|
|
364
|
+
(1, 4, 1): "Vacanza Tarino",
|
|
365
|
+
(1, 4, 2): "Vacanza Capitolo",
|
|
366
|
+
(1, 4, 3): "Vacanza Celio",
|
|
367
|
+
(1, 4, 4): "Vacanza Platino",
|
|
368
|
+
(1, 4, 5): "Vacanza Vanto",
|
|
369
|
+
(1, 4, 6): "Vacanza Marino",
|
|
370
|
+
(1, 4, 7): "Vacanza Tarino_can",
|
|
371
|
+
(1, 4, 8): "Vacanza Aventine",
|
|
372
|
+
(1, 4, 9): "Vacanza Tarino",
|
|
373
|
+
(1, 4, 10): "Vacanza Capitolo",
|
|
374
|
+
(1, 4, 11): "Vacanza Celio",
|
|
375
|
+
(1, 4, 12): "Vacanza Marino",
|
|
376
|
+
(1, 4, 13): "Vacanza Platino",
|
|
377
|
+
(1, 4, 14): "Vacanza Vanto",
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def resolve_spa_model(
|
|
382
|
+
brand_raw: str | int | None,
|
|
383
|
+
collection_raw: str | int | None,
|
|
384
|
+
model_raw: str | int | None,
|
|
385
|
+
) -> tuple[SpaBrand, str, str]:
|
|
386
|
+
"""Resolve raw API brand, collection, and model IDs to human-readable strings.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
----
|
|
390
|
+
brand_raw: Raw brand string or int from API (e.g. "0" or "1").
|
|
391
|
+
collection_raw: Raw collection string or int from API (e.g. "1").
|
|
392
|
+
model_raw: Raw model string or int from API (e.g. "4").
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
-------
|
|
396
|
+
Tuple of (SpaBrand enum, collection name, model name).
|
|
397
|
+
|
|
398
|
+
"""
|
|
399
|
+
brand = SpaBrand.build(brand_raw)
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
brand_id = int(str(brand_raw)) if brand_raw is not None else -1
|
|
403
|
+
collection_id = int(str(collection_raw)) if collection_raw is not None else -1
|
|
404
|
+
model_id = int(str(model_raw)) if model_raw is not None else -1
|
|
405
|
+
except ValueError:
|
|
406
|
+
return (brand, "Unknown", "Unknown")
|
|
407
|
+
|
|
408
|
+
collection = SPA_COLLECTION_MAP.get((brand_id, collection_id), "Unknown")
|
|
409
|
+
model_name = SPA_MODEL_MAP.get((brand_id, collection_id, model_id), "Unknown")
|
|
410
|
+
|
|
411
|
+
return (brand, collection, model_name)
|
|
@@ -16,8 +16,10 @@ from .const import (
|
|
|
16
16
|
JetSpeed,
|
|
17
17
|
LightColor,
|
|
18
18
|
LightWheelMode,
|
|
19
|
+
SpaBrand,
|
|
19
20
|
SpaFailureState,
|
|
20
21
|
TemperatureUnit,
|
|
22
|
+
resolve_spa_model,
|
|
21
23
|
)
|
|
22
24
|
|
|
23
25
|
|
|
@@ -118,13 +120,20 @@ class Spa:
|
|
|
118
120
|
status = model_data.get("status", {})
|
|
119
121
|
if isinstance(status, dict):
|
|
120
122
|
if "brandName" in status:
|
|
121
|
-
self.info.
|
|
123
|
+
self.info.brand_id = str(status["brandName"])
|
|
122
124
|
if "collectionType" in status:
|
|
123
|
-
self.info.
|
|
125
|
+
self.info.collection_id = str(status["collectionType"])
|
|
124
126
|
if "modelType" in status:
|
|
125
|
-
self.info.
|
|
127
|
+
self.info.model_id = str(status["modelType"])
|
|
126
128
|
if "volume" in status:
|
|
127
129
|
self.info.volume = int(status["volume"] or 0)
|
|
130
|
+
brand, collection, model_name = resolve_spa_model(
|
|
131
|
+
self.info.brand_id, self.info.collection_id, self.info.model_id
|
|
132
|
+
)
|
|
133
|
+
self.info.brand = brand
|
|
134
|
+
self.info.brand_name = brand.value
|
|
135
|
+
self.info.collection = collection
|
|
136
|
+
self.info.model_name = model_name
|
|
128
137
|
|
|
129
138
|
def update_connection_status(self, data: dict[str, object]) -> None:
|
|
130
139
|
"""Update connection status from /spaConnectStatus response.
|
|
@@ -167,11 +176,25 @@ class SpaInfo:
|
|
|
167
176
|
hostname: str
|
|
168
177
|
root_topic: str
|
|
169
178
|
sna_ready: bool
|
|
179
|
+
brand: SpaBrand
|
|
170
180
|
brand_name: str
|
|
171
|
-
|
|
172
|
-
|
|
181
|
+
collection: str
|
|
182
|
+
model_name: str
|
|
183
|
+
brand_id: str
|
|
184
|
+
collection_id: str
|
|
185
|
+
model_id: str
|
|
173
186
|
volume: int
|
|
174
187
|
|
|
188
|
+
@property
|
|
189
|
+
def collection_type(self) -> str:
|
|
190
|
+
"""Alias for collection_id for backward compatibility."""
|
|
191
|
+
return self.collection_id
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def model_type(self) -> str:
|
|
195
|
+
"""Alias for model_id for backward compatibility."""
|
|
196
|
+
return self.model_id
|
|
197
|
+
|
|
175
198
|
@staticmethod
|
|
176
199
|
def from_dict(data: dict[str, object]) -> SpaInfo:
|
|
177
200
|
"""Create a SpaInfo from API response data.
|
|
@@ -192,16 +215,53 @@ class SpaInfo:
|
|
|
192
215
|
if isinstance(status, dict):
|
|
193
216
|
model_status = status
|
|
194
217
|
|
|
218
|
+
brand_id = str(model_status.get("brandName", ""))
|
|
219
|
+
collection_id = str(model_status.get("collectionType", ""))
|
|
220
|
+
model_id = str(model_status.get("modelType", ""))
|
|
221
|
+
brand, collection, model_name = resolve_spa_model(
|
|
222
|
+
brand_id, collection_id, model_id
|
|
223
|
+
)
|
|
224
|
+
|
|
195
225
|
return SpaInfo(
|
|
196
226
|
hostname=str(data.get("HOSTNAME", "")),
|
|
197
227
|
root_topic=str(data.get("rootTopic", "")),
|
|
198
228
|
sna_ready=data.get("SNAready", "") in ("Ready", "Yes"),
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
229
|
+
brand=brand,
|
|
230
|
+
brand_name=brand.value,
|
|
231
|
+
collection=collection,
|
|
232
|
+
model_name=model_name,
|
|
233
|
+
brand_id=brand_id,
|
|
234
|
+
collection_id=collection_id,
|
|
235
|
+
model_id=model_id,
|
|
202
236
|
volume=int(model_status.get("volume") or 0),
|
|
203
237
|
)
|
|
204
238
|
|
|
239
|
+
@property
|
|
240
|
+
def mac_address(self) -> str:
|
|
241
|
+
"""Derive the MAC address from root_topic.
|
|
242
|
+
|
|
243
|
+
The HNA firmware builds root_topic as ``mySpa%02X%02X%02X%02X%02X%02X``
|
|
244
|
+
using the device's 6-byte WiFi MAC, so the 12 hex characters after the
|
|
245
|
+
``mySpa`` prefix are the full MAC address.
|
|
246
|
+
|
|
247
|
+
Returns
|
|
248
|
+
-------
|
|
249
|
+
Colon-separated uppercase MAC (e.g. ``"AA:BB:CC:11:22:33"``),
|
|
250
|
+
or ``""`` if root_topic does not match the expected format.
|
|
251
|
+
|
|
252
|
+
"""
|
|
253
|
+
prefix = "mySpa"
|
|
254
|
+
if not self.root_topic.startswith(prefix):
|
|
255
|
+
return ""
|
|
256
|
+
mac_hex = self.root_topic[len(prefix) :]
|
|
257
|
+
try:
|
|
258
|
+
mac_bytes = bytes.fromhex(mac_hex)
|
|
259
|
+
except ValueError:
|
|
260
|
+
return ""
|
|
261
|
+
if len(mac_bytes) != 6:
|
|
262
|
+
return ""
|
|
263
|
+
return ":".join(f"{b:02X}" for b in mac_bytes)
|
|
264
|
+
|
|
205
265
|
|
|
206
266
|
@dataclass
|
|
207
267
|
class Heater: # pylint: disable=too-many-instance-attributes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|