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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hotspring
3
- Version: 1.1.0
3
+ Version: 1.3.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 = "1.1.0"
25
+ version = "1.3.0"
26
26
 
27
27
  [tool.poetry.dependencies]
28
28
  aiohttp = ">=3.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.brand_name = str(status["brandName"])
123
+ self.info.brand_id = str(status["brandName"])
122
124
  if "collectionType" in status:
123
- self.info.collection_type = str(status["collectionType"])
125
+ self.info.collection_id = str(status["collectionType"])
124
126
  if "modelType" in status:
125
- self.info.model_type = str(status["modelType"])
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
- collection_type: str
172
- model_type: str
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
- brand_name=str(model_status.get("brandName", "")),
200
- collection_type=str(model_status.get("collectionType", "")),
201
- model_type=str(model_status.get("modelType", "")),
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