python-hotspring 1.0.0__py3-none-any.whl
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.
- hotspring/__init__.py +67 -0
- hotspring/const.py +237 -0
- hotspring/exceptions.py +28 -0
- hotspring/hotspring.py +487 -0
- hotspring/models.py +762 -0
- hotspring/py.typed +0 -0
- python_hotspring-1.0.0.dist-info/METADATA +208 -0
- python_hotspring-1.0.0.dist-info/RECORD +10 -0
- python_hotspring-1.0.0.dist-info/WHEEL +4 -0
- python_hotspring-1.0.0.dist-info/licenses/LICENSE +21 -0
hotspring/models.py
ADDED
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
"""Models for Hot Spring Connected Spa Kit 2."""
|
|
2
|
+
# mypy: disable-error-code="union-attr, arg-type, call-overload, attr-defined"
|
|
3
|
+
# Rationale: This module parses nested JSON dicts typed as dict[str, object].
|
|
4
|
+
# The .get() return type is `object`, which mypy cannot narrow without runtime
|
|
5
|
+
# isinstance checks on every access. This is a known typing limitation for
|
|
6
|
+
# hand-parsed JSON; schema libraries (mashumaro, pydantic) solve this at the
|
|
7
|
+
# cost of an extra dependency.
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from .const import (
|
|
14
|
+
BrightnessLevel,
|
|
15
|
+
HeatingMode,
|
|
16
|
+
JetSpeed,
|
|
17
|
+
LightColor,
|
|
18
|
+
LightWheelMode,
|
|
19
|
+
SpaFailureState,
|
|
20
|
+
TemperatureUnit,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Spa:
|
|
25
|
+
"""Object holding all information from a Hot Spring Spa.
|
|
26
|
+
|
|
27
|
+
This is the top-level container that aggregates all sub-models
|
|
28
|
+
parsed from the various API endpoints.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
info: SpaInfo
|
|
32
|
+
heater: Heater
|
|
33
|
+
jets: list[Jet]
|
|
34
|
+
blower: Blower
|
|
35
|
+
light_zones: list[LightZone]
|
|
36
|
+
logo_light: LogoLight
|
|
37
|
+
clean_cycle: CleanCycle
|
|
38
|
+
spa_lock: SpaLock
|
|
39
|
+
water_care: WaterCare
|
|
40
|
+
freshwater_iq: FreshWaterIQ
|
|
41
|
+
energy_savings: list[EnergySaving]
|
|
42
|
+
versions: Versions
|
|
43
|
+
connection_status: ConnectionStatus
|
|
44
|
+
diagnostics: Diagnostics
|
|
45
|
+
|
|
46
|
+
def __init__(self, data: dict[str, object]) -> None:
|
|
47
|
+
"""Initialize a Spa from the full API response.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
----
|
|
51
|
+
data: The full API response from a GET /status call.
|
|
52
|
+
|
|
53
|
+
"""
|
|
54
|
+
self.update_from_dict(data)
|
|
55
|
+
|
|
56
|
+
def update_from_dict(self, data: dict[str, object]) -> Spa:
|
|
57
|
+
"""Update the Spa object from a /status API response.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
----
|
|
61
|
+
data: The full JSON response from GET /status.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
-------
|
|
65
|
+
The updated Spa object.
|
|
66
|
+
|
|
67
|
+
"""
|
|
68
|
+
self.heater = Heater.from_dict(data.get("heater", {}))
|
|
69
|
+
self.jets = Jet.list_from_dict(data.get("JET", {}))
|
|
70
|
+
self.blower = Blower.from_dict(data.get("blower", {}))
|
|
71
|
+
self.light_zones = LightZone.list_from_dict(data.get("lights", {}))
|
|
72
|
+
self.logo_light = LogoLight.from_dict(data.get("logoLight", {}))
|
|
73
|
+
self.clean_cycle = CleanCycle.from_dict(data.get("cleanCycle", {}))
|
|
74
|
+
self.spa_lock = SpaLock.from_dict(data.get("spaLock", {}))
|
|
75
|
+
self.water_care = WaterCare.from_dict(data.get("waterCare", {}))
|
|
76
|
+
self.freshwater_iq = FreshWaterIQ.from_dict(data.get("FWIQ_Parameters", {}))
|
|
77
|
+
self.energy_savings = EnergySaving.list_from_dict(data.get("energySavings", {}))
|
|
78
|
+
self.versions = Versions.from_dict(
|
|
79
|
+
data.get("productVersions", {}).get("status", {})
|
|
80
|
+
)
|
|
81
|
+
# These are populated by separate API calls
|
|
82
|
+
if not hasattr(self, "info"):
|
|
83
|
+
self.info = SpaInfo.from_dict({})
|
|
84
|
+
if not hasattr(self, "connection_status"):
|
|
85
|
+
self.connection_status = ConnectionStatus.from_dict({})
|
|
86
|
+
if not hasattr(self, "diagnostics"):
|
|
87
|
+
self.diagnostics = Diagnostics.from_dict({})
|
|
88
|
+
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
def update_info(self, data: dict[str, object]) -> None:
|
|
92
|
+
"""Update spa identity from /startup and /spamodel responses.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
----
|
|
96
|
+
data: Combined data from /startup and /spamodel endpoints.
|
|
97
|
+
|
|
98
|
+
"""
|
|
99
|
+
self.info = SpaInfo.from_dict(data)
|
|
100
|
+
|
|
101
|
+
def update_connection_status(self, data: dict[str, object]) -> None:
|
|
102
|
+
"""Update connection status from /spaConnectStatus response.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
----
|
|
106
|
+
data: The JSON response from GET /spaConnectStatus.
|
|
107
|
+
|
|
108
|
+
"""
|
|
109
|
+
self.connection_status = ConnectionStatus.from_dict(data)
|
|
110
|
+
|
|
111
|
+
def update_diagnostics(self, data: dict[str, object]) -> None:
|
|
112
|
+
"""Update diagnostics from /addDebugData response.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
----
|
|
116
|
+
data: The JSON response from GET /addDebugData.
|
|
117
|
+
|
|
118
|
+
"""
|
|
119
|
+
self.diagnostics = Diagnostics.from_dict(data)
|
|
120
|
+
|
|
121
|
+
def update_freshwater_iq(self, data: dict[str, object]) -> None:
|
|
122
|
+
"""Update FreshWater IQ data from /getFWIQData response.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
----
|
|
126
|
+
data: The JSON response from GET /getFWIQData.
|
|
127
|
+
|
|
128
|
+
"""
|
|
129
|
+
self.freshwater_iq = FreshWaterIQ.from_dict(data)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class SpaInfo:
|
|
134
|
+
"""Spa identity and configuration information.
|
|
135
|
+
|
|
136
|
+
Populated from the /startup and /spamodel endpoints.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
hostname: str
|
|
140
|
+
mac_address: str
|
|
141
|
+
model: str
|
|
142
|
+
ssid: str
|
|
143
|
+
sna_ready: bool
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def from_dict(data: dict[str, object]) -> SpaInfo:
|
|
147
|
+
"""Create a SpaInfo from API response data.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
----
|
|
151
|
+
data: Combined data from /startup and /spamodel.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
-------
|
|
155
|
+
A SpaInfo instance.
|
|
156
|
+
|
|
157
|
+
"""
|
|
158
|
+
return SpaInfo(
|
|
159
|
+
hostname=data.get("HOSTNAME", ""),
|
|
160
|
+
mac_address=data.get("MAC", ""),
|
|
161
|
+
model=data.get("model", ""),
|
|
162
|
+
ssid=data.get("SSID", ""),
|
|
163
|
+
sna_ready=data.get("SNAready", "") in ("Ready", "Yes"),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass
|
|
168
|
+
class Heater: # pylint: disable=too-many-instance-attributes
|
|
169
|
+
"""Heater status and configuration."""
|
|
170
|
+
|
|
171
|
+
is_on: bool
|
|
172
|
+
heater_lock: bool
|
|
173
|
+
heatpump_installed: bool
|
|
174
|
+
heating_mode: HeatingMode
|
|
175
|
+
heater_current: int
|
|
176
|
+
heater_hours: int
|
|
177
|
+
set_temperature: float | None
|
|
178
|
+
current_temperature: float | None
|
|
179
|
+
temperature_unit: TemperatureUnit
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def from_dict(data: dict[str, object]) -> Heater:
|
|
183
|
+
"""Create a Heater from API response data.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
----
|
|
187
|
+
data: The ``heater`` dict from the /status response.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
-------
|
|
191
|
+
A Heater instance.
|
|
192
|
+
|
|
193
|
+
"""
|
|
194
|
+
status = data.get("status", {})
|
|
195
|
+
return Heater(
|
|
196
|
+
is_on=status.get("heater", "off") != "off",
|
|
197
|
+
heater_lock=status.get("heaterLock", "off") != "off",
|
|
198
|
+
heatpump_installed=status.get("heatpumpInstalled", "notinstalled")
|
|
199
|
+
!= "notinstalled",
|
|
200
|
+
heating_mode=HeatingMode.build(status.get("heatingMode")),
|
|
201
|
+
heater_current=int(status.get("heaterCurrent", 0)),
|
|
202
|
+
heater_hours=int(status.get("heaterHours", 0)),
|
|
203
|
+
set_temperature=_parse_temperature(status.get("setWaterTemperature")),
|
|
204
|
+
current_temperature=_parse_temperature(
|
|
205
|
+
status.get("currentWaterTemperature")
|
|
206
|
+
),
|
|
207
|
+
temperature_unit=TemperatureUnit.build(status.get("temperatureUnit")),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass
|
|
212
|
+
class Jet:
|
|
213
|
+
"""Status and configuration for a single jet pump."""
|
|
214
|
+
|
|
215
|
+
jet_id: int
|
|
216
|
+
speed: JetSpeed
|
|
217
|
+
is_enabled: bool
|
|
218
|
+
on_seconds: int
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def from_dict(jet_id: int, data: dict[str, object]) -> Jet:
|
|
222
|
+
"""Create a Jet from API response data.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
----
|
|
226
|
+
jet_id: The jet number (1-based).
|
|
227
|
+
data: The ``JETn`` dict from the /status response.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
-------
|
|
231
|
+
A Jet instance.
|
|
232
|
+
|
|
233
|
+
"""
|
|
234
|
+
config = data.get("config", {})
|
|
235
|
+
status = data.get("status", {})
|
|
236
|
+
|
|
237
|
+
# A jet is disabled if its key in config is set to "disable"
|
|
238
|
+
jet_key = f"JET{jet_id}"
|
|
239
|
+
is_enabled = config.get(jet_key, "enable") != "disable"
|
|
240
|
+
|
|
241
|
+
# Find the on_seconds key dynamically (e.g., jet_1_ON_sec)
|
|
242
|
+
on_sec_key = f"jet_{jet_id}_ON_sec"
|
|
243
|
+
on_seconds = int(status.get(on_sec_key, 0))
|
|
244
|
+
|
|
245
|
+
return Jet(
|
|
246
|
+
jet_id=jet_id,
|
|
247
|
+
speed=JetSpeed.build(status.get("speed")),
|
|
248
|
+
is_enabled=is_enabled,
|
|
249
|
+
on_seconds=on_seconds,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def list_from_dict(data: dict[str, object]) -> list[Jet]:
|
|
254
|
+
"""Parse all jets from the JET section of the /status response.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
----
|
|
258
|
+
data: The ``JET`` dict from the /status response.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
-------
|
|
262
|
+
A list of Jet instances.
|
|
263
|
+
|
|
264
|
+
"""
|
|
265
|
+
jets: list[Jet] = []
|
|
266
|
+
for key, value in data.items():
|
|
267
|
+
if key.startswith("JET") and isinstance(value, dict):
|
|
268
|
+
try:
|
|
269
|
+
jet_id = int(key[3:])
|
|
270
|
+
except ValueError:
|
|
271
|
+
continue
|
|
272
|
+
jets.append(Jet.from_dict(jet_id, value))
|
|
273
|
+
return sorted(jets, key=lambda j: j.jet_id)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@dataclass
|
|
277
|
+
class Blower:
|
|
278
|
+
"""Blower status and configuration."""
|
|
279
|
+
|
|
280
|
+
is_enabled: bool
|
|
281
|
+
is_on: bool
|
|
282
|
+
|
|
283
|
+
@staticmethod
|
|
284
|
+
def from_dict(data: dict[str, object]) -> Blower:
|
|
285
|
+
"""Create a Blower from API response data.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
----
|
|
289
|
+
data: The ``blower`` dict from the /status response.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
-------
|
|
293
|
+
A Blower instance.
|
|
294
|
+
|
|
295
|
+
"""
|
|
296
|
+
config = data.get("config", {})
|
|
297
|
+
status = data.get("status", {})
|
|
298
|
+
return Blower(
|
|
299
|
+
is_enabled=config.get("blower", "disable") != "disable",
|
|
300
|
+
is_on=status.get("blower", "off") not in ("off", "disable"),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@dataclass
|
|
305
|
+
class LightZone:
|
|
306
|
+
"""Status and configuration for a single light zone."""
|
|
307
|
+
|
|
308
|
+
zone_id: int
|
|
309
|
+
is_enabled: bool
|
|
310
|
+
is_on: bool
|
|
311
|
+
color: LightColor
|
|
312
|
+
light_wheel: LightWheelMode
|
|
313
|
+
intensity: int
|
|
314
|
+
loop_speed: int
|
|
315
|
+
|
|
316
|
+
@staticmethod
|
|
317
|
+
def from_dict(zone_id: int, data: dict[str, object]) -> LightZone:
|
|
318
|
+
"""Create a LightZone from API response data.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
----
|
|
322
|
+
zone_id: The zone number (1-based).
|
|
323
|
+
data: The ``zoneN`` dict from the /status response.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
-------
|
|
327
|
+
A LightZone instance.
|
|
328
|
+
|
|
329
|
+
"""
|
|
330
|
+
config = data.get("config", {})
|
|
331
|
+
status = data.get("status", {})
|
|
332
|
+
|
|
333
|
+
zone_key = f"zone_{zone_id}"
|
|
334
|
+
is_enabled = config.get(zone_key, "disable") != "disable"
|
|
335
|
+
|
|
336
|
+
color = LightColor.build(status.get("color"))
|
|
337
|
+
light_wheel = LightWheelMode.build(status.get("lightWheel"))
|
|
338
|
+
is_on = color not in (
|
|
339
|
+
LightColor.OFF,
|
|
340
|
+
LightColor.UNKNOWN,
|
|
341
|
+
) or light_wheel not in (LightWheelMode.OFF, LightWheelMode.UNKNOWN)
|
|
342
|
+
|
|
343
|
+
return LightZone(
|
|
344
|
+
zone_id=zone_id,
|
|
345
|
+
is_enabled=is_enabled,
|
|
346
|
+
is_on=is_on,
|
|
347
|
+
color=color,
|
|
348
|
+
light_wheel=light_wheel,
|
|
349
|
+
intensity=int(status.get("Intensity", 0)),
|
|
350
|
+
loop_speed=int(status.get("loopSpeed", 0)),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
@staticmethod
|
|
354
|
+
def list_from_dict(data: dict[str, object]) -> list[LightZone]:
|
|
355
|
+
"""Parse all light zones from the lights section.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
----
|
|
359
|
+
data: The ``lights`` dict from the /status response.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
-------
|
|
363
|
+
A list of LightZone instances.
|
|
364
|
+
|
|
365
|
+
"""
|
|
366
|
+
zones: list[LightZone] = []
|
|
367
|
+
for key, value in data.items():
|
|
368
|
+
if key.startswith("zone") and isinstance(value, dict):
|
|
369
|
+
try:
|
|
370
|
+
zone_id = int(key[4:])
|
|
371
|
+
except ValueError:
|
|
372
|
+
continue
|
|
373
|
+
zones.append(LightZone.from_dict(zone_id, value))
|
|
374
|
+
return sorted(zones, key=lambda z: z.zone_id)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@dataclass
|
|
378
|
+
class LogoLight:
|
|
379
|
+
"""Logo light status."""
|
|
380
|
+
|
|
381
|
+
brightness: BrightnessLevel
|
|
382
|
+
|
|
383
|
+
@staticmethod
|
|
384
|
+
def from_dict(data: dict[str, object]) -> LogoLight:
|
|
385
|
+
"""Create a LogoLight from API response data.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
----
|
|
389
|
+
data: The ``logoLight`` dict from the /status response.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
-------
|
|
393
|
+
A LogoLight instance.
|
|
394
|
+
|
|
395
|
+
"""
|
|
396
|
+
status = data.get("status", {})
|
|
397
|
+
return LogoLight(
|
|
398
|
+
brightness=BrightnessLevel.build(status.get("brightness")),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@dataclass
|
|
403
|
+
class CleanCycle:
|
|
404
|
+
"""Clean cycle status and configuration."""
|
|
405
|
+
|
|
406
|
+
is_enabled: bool
|
|
407
|
+
vanishing_act: bool
|
|
408
|
+
|
|
409
|
+
@staticmethod
|
|
410
|
+
def from_dict(data: dict[str, object]) -> CleanCycle:
|
|
411
|
+
"""Create a CleanCycle from API response data.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
----
|
|
415
|
+
data: The ``cleanCycle`` dict from the /status response.
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
-------
|
|
419
|
+
A CleanCycle instance.
|
|
420
|
+
|
|
421
|
+
"""
|
|
422
|
+
status = data.get("status", {})
|
|
423
|
+
return CleanCycle(
|
|
424
|
+
is_enabled=status.get("cleanCycle", "disable") == "enable",
|
|
425
|
+
vanishing_act=status.get("vanishingAct", "off") != "off",
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@dataclass
|
|
430
|
+
class SpaLock:
|
|
431
|
+
"""Spa lock status."""
|
|
432
|
+
|
|
433
|
+
is_locked: bool
|
|
434
|
+
|
|
435
|
+
@staticmethod
|
|
436
|
+
def from_dict(data: dict[str, object]) -> SpaLock:
|
|
437
|
+
"""Create a SpaLock from API response data.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
----
|
|
441
|
+
data: The ``spaLock`` dict from the /status response.
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
-------
|
|
445
|
+
A SpaLock instance.
|
|
446
|
+
|
|
447
|
+
"""
|
|
448
|
+
status = data.get("status", {})
|
|
449
|
+
return SpaLock(
|
|
450
|
+
is_locked=status.get("spaLock", "off") != "off",
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@dataclass
|
|
455
|
+
class WaterCare: # pylint: disable=too-many-instance-attributes
|
|
456
|
+
"""Water care / salt system status."""
|
|
457
|
+
|
|
458
|
+
cartridge_installed: bool
|
|
459
|
+
ten_day_timer: int
|
|
460
|
+
one_twenty_day_timer: int
|
|
461
|
+
level: int
|
|
462
|
+
system_enabled: bool
|
|
463
|
+
ace_mode: str
|
|
464
|
+
boost_active: bool
|
|
465
|
+
salt_level: str
|
|
466
|
+
salt_value: int
|
|
467
|
+
|
|
468
|
+
@staticmethod
|
|
469
|
+
def from_dict(data: dict[str, object]) -> WaterCare:
|
|
470
|
+
"""Create a WaterCare from API response data.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
----
|
|
474
|
+
data: The ``waterCare`` dict from the /status response.
|
|
475
|
+
|
|
476
|
+
Returns:
|
|
477
|
+
-------
|
|
478
|
+
A WaterCare instance.
|
|
479
|
+
|
|
480
|
+
"""
|
|
481
|
+
status = data.get("status", {})
|
|
482
|
+
return WaterCare(
|
|
483
|
+
cartridge_installed=status.get("cartridgeInstalled", "notinstalled")
|
|
484
|
+
!= "notinstalled",
|
|
485
|
+
ten_day_timer=int(status.get("10DayTimer", 0)),
|
|
486
|
+
one_twenty_day_timer=int(status.get("120DayTimer", 0)),
|
|
487
|
+
level=int(status.get("level", 0)),
|
|
488
|
+
system_enabled=status.get("SystemEnable", "disable") == "enable",
|
|
489
|
+
ace_mode=status.get("AceMode", "inactive"),
|
|
490
|
+
boost_active=status.get("boost", "inactive") != "inactive",
|
|
491
|
+
salt_level=status.get("saltLevel", ""),
|
|
492
|
+
salt_value=int(status.get("saltValue", 0)),
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@dataclass
|
|
497
|
+
class FreshWaterIQ:
|
|
498
|
+
"""FreshWater IQ water quality sensor data."""
|
|
499
|
+
|
|
500
|
+
conductivity: int
|
|
501
|
+
orp: int
|
|
502
|
+
chlorine: float
|
|
503
|
+
ph: float
|
|
504
|
+
sensor_life_percentage: float
|
|
505
|
+
|
|
506
|
+
installed: bool
|
|
507
|
+
|
|
508
|
+
@staticmethod
|
|
509
|
+
def from_dict(data: dict[str, object]) -> FreshWaterIQ:
|
|
510
|
+
"""Create a FreshWaterIQ from API response data.
|
|
511
|
+
|
|
512
|
+
Handles two response formats:
|
|
513
|
+
- ``FWIQ_Parameters`` from /status (flat keys)
|
|
514
|
+
- ``waterCare.status.FWIQstatus`` from /getFWIQData (nested)
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
----
|
|
518
|
+
data: Data from either source.
|
|
519
|
+
|
|
520
|
+
Returns:
|
|
521
|
+
-------
|
|
522
|
+
A FreshWaterIQ instance.
|
|
523
|
+
|
|
524
|
+
"""
|
|
525
|
+
# Handle the nested /getFWIQData format
|
|
526
|
+
fwiq = data.get("waterCare", {}).get("status", {}).get("FWIQstatus", {})
|
|
527
|
+
if fwiq:
|
|
528
|
+
return FreshWaterIQ(
|
|
529
|
+
conductivity=int(fwiq.get("Conductivity", 0)),
|
|
530
|
+
orp=int(fwiq.get("ORP", 0)),
|
|
531
|
+
chlorine=float(fwiq.get("Chlorine", 0.0)),
|
|
532
|
+
ph=float(fwiq.get("pH", 0.0)),
|
|
533
|
+
sensor_life_percentage=float(fwiq.get("SensorLife", 0.0)),
|
|
534
|
+
installed=fwiq.get("FWIQinstalled", "notinstalled") != "notinstalled",
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
# Handle the flat /status FWIQ_Parameters format
|
|
538
|
+
return FreshWaterIQ(
|
|
539
|
+
conductivity=int(data.get("current_Current_CompConductivity", 0)),
|
|
540
|
+
orp=int(data.get("current_ORP", 0)),
|
|
541
|
+
chlorine=float(data.get("current_chlorine", 0.0)),
|
|
542
|
+
ph=float(data.get("current_pH", 0.0)),
|
|
543
|
+
sensor_life_percentage=float(
|
|
544
|
+
data.get("current_SensorLife_Percentage", 0.0)
|
|
545
|
+
),
|
|
546
|
+
installed=True, # Assume installed if using flat format
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
@dataclass
|
|
551
|
+
class EnergySaving:
|
|
552
|
+
"""Energy saving schedule configuration."""
|
|
553
|
+
|
|
554
|
+
schedule_id: int
|
|
555
|
+
mode: int
|
|
556
|
+
start_hour: int
|
|
557
|
+
start_minute: int
|
|
558
|
+
duration: int
|
|
559
|
+
|
|
560
|
+
@staticmethod
|
|
561
|
+
def from_dict(schedule_id: int, data: dict[str, object]) -> EnergySaving:
|
|
562
|
+
"""Create an EnergySaving from API response data.
|
|
563
|
+
|
|
564
|
+
Args:
|
|
565
|
+
----
|
|
566
|
+
schedule_id: The schedule number (1-based).
|
|
567
|
+
data: The ``energySavingN`` dict from the /status response.
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
-------
|
|
571
|
+
An EnergySaving instance.
|
|
572
|
+
|
|
573
|
+
"""
|
|
574
|
+
status = data.get("status", {})
|
|
575
|
+
return EnergySaving(
|
|
576
|
+
schedule_id=schedule_id,
|
|
577
|
+
mode=int(status.get("mode", 0)),
|
|
578
|
+
start_hour=int(status.get("startHour", 0)),
|
|
579
|
+
start_minute=int(status.get("startMinute", 0)),
|
|
580
|
+
duration=int(status.get("duration", 0)),
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
@staticmethod
|
|
584
|
+
def list_from_dict(data: dict[str, object]) -> list[EnergySaving]:
|
|
585
|
+
"""Parse all energy saving schedules from the /status response.
|
|
586
|
+
|
|
587
|
+
Args:
|
|
588
|
+
----
|
|
589
|
+
data: The ``energySavings`` dict from the /status response.
|
|
590
|
+
|
|
591
|
+
Returns:
|
|
592
|
+
-------
|
|
593
|
+
A list of EnergySaving instances.
|
|
594
|
+
|
|
595
|
+
"""
|
|
596
|
+
schedules: list[EnergySaving] = []
|
|
597
|
+
for key, value in data.items():
|
|
598
|
+
if key.startswith("energySaving") and isinstance(value, dict):
|
|
599
|
+
try:
|
|
600
|
+
schedule_id = int(key[12:])
|
|
601
|
+
except ValueError:
|
|
602
|
+
continue
|
|
603
|
+
schedules.append(EnergySaving.from_dict(schedule_id, value))
|
|
604
|
+
return sorted(schedules, key=lambda s: s.schedule_id)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
@dataclass
|
|
608
|
+
class Versions: # pylint: disable=too-many-instance-attributes
|
|
609
|
+
"""Firmware versions for all spa sub-components."""
|
|
610
|
+
|
|
611
|
+
control_box: str
|
|
612
|
+
control_panel: str
|
|
613
|
+
fwss: str
|
|
614
|
+
fwiq: str
|
|
615
|
+
btxr: str
|
|
616
|
+
cool_zone: str
|
|
617
|
+
wifi_dongle: str
|
|
618
|
+
amp: str
|
|
619
|
+
dosing: str
|
|
620
|
+
logolight: str
|
|
621
|
+
|
|
622
|
+
@staticmethod
|
|
623
|
+
def from_dict(data: dict[str, object]) -> Versions:
|
|
624
|
+
"""Create a Versions from API response data.
|
|
625
|
+
|
|
626
|
+
Args:
|
|
627
|
+
----
|
|
628
|
+
data: The ``productVersions.status`` dict from /status
|
|
629
|
+
or from GET /versions.
|
|
630
|
+
|
|
631
|
+
Returns:
|
|
632
|
+
-------
|
|
633
|
+
A Versions instance.
|
|
634
|
+
|
|
635
|
+
"""
|
|
636
|
+
return Versions(
|
|
637
|
+
control_box=data.get("ControlBoxFirmwareVersion", ""),
|
|
638
|
+
control_panel=data.get("ControlPanelFirmwareVersion", ""),
|
|
639
|
+
fwss=data.get("FWSSFirmwareVersion", ""),
|
|
640
|
+
fwiq=data.get("FWIQFirmwareVersion", ""),
|
|
641
|
+
btxr=data.get("BTXRFirmwareVersion", ""),
|
|
642
|
+
cool_zone=data.get("CoolZoneFirmwareVersion", ""),
|
|
643
|
+
wifi_dongle=data.get("WiFiDongleVersion", ""),
|
|
644
|
+
amp=data.get("AMPFirmwareVersion", ""),
|
|
645
|
+
dosing=data.get("DosingFirmwareVersion", ""),
|
|
646
|
+
logolight=data.get("LogolightFirmwareVersion", ""),
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
@dataclass
|
|
651
|
+
class ConnectionStatus:
|
|
652
|
+
"""Connection status between the HNA, SNA, and cloud."""
|
|
653
|
+
|
|
654
|
+
spa_connected: bool
|
|
655
|
+
|
|
656
|
+
@staticmethod
|
|
657
|
+
def from_dict(data: dict[str, object]) -> ConnectionStatus:
|
|
658
|
+
"""Create a ConnectionStatus from API response data.
|
|
659
|
+
|
|
660
|
+
The real API returns ``{"spaConnectStatus": "true"}`` as a single
|
|
661
|
+
field, not separate cloud/sna booleans.
|
|
662
|
+
|
|
663
|
+
Args:
|
|
664
|
+
----
|
|
665
|
+
data: The JSON response from GET /spaConnectStatus.
|
|
666
|
+
|
|
667
|
+
Returns:
|
|
668
|
+
-------
|
|
669
|
+
A ConnectionStatus instance.
|
|
670
|
+
|
|
671
|
+
"""
|
|
672
|
+
raw = data.get("spaConnectStatus", "false")
|
|
673
|
+
connected = str(raw).lower() in ("true", "1")
|
|
674
|
+
return ConnectionStatus(
|
|
675
|
+
spa_connected=connected,
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
@dataclass
|
|
680
|
+
class Diagnostics: # pylint: disable=too-many-instance-attributes
|
|
681
|
+
"""Diagnostic and power metrics from the spa.
|
|
682
|
+
|
|
683
|
+
Availability depends on the spa model and whether the main control
|
|
684
|
+
board (IQ2020/Eagle) is equipped with current-sensing transformers.
|
|
685
|
+
Values may be ``0`` if sensors are not installed.
|
|
686
|
+
"""
|
|
687
|
+
|
|
688
|
+
spa_failure_state: SpaFailureState
|
|
689
|
+
heater_error: str
|
|
690
|
+
power_frequency: str
|
|
691
|
+
pressure_switch_status: str
|
|
692
|
+
l1_n_volts: str
|
|
693
|
+
l2_n_volts: str
|
|
694
|
+
heater_volts: str
|
|
695
|
+
jet3_volts: str
|
|
696
|
+
jet1_jet2_blower_power: str
|
|
697
|
+
small_loads_power: str
|
|
698
|
+
heater_power: str
|
|
699
|
+
jet3_power: str
|
|
700
|
+
|
|
701
|
+
@staticmethod
|
|
702
|
+
def from_dict(data: dict[str, object]) -> Diagnostics:
|
|
703
|
+
"""Create a Diagnostics from API response data.
|
|
704
|
+
|
|
705
|
+
Args:
|
|
706
|
+
----
|
|
707
|
+
data: The JSON response from GET /addDebugData, or an empty
|
|
708
|
+
dict for default values.
|
|
709
|
+
|
|
710
|
+
Returns:
|
|
711
|
+
-------
|
|
712
|
+
A Diagnostics instance.
|
|
713
|
+
|
|
714
|
+
"""
|
|
715
|
+
debug = data.get("debugData", {}).get("status", {})
|
|
716
|
+
return Diagnostics(
|
|
717
|
+
spa_failure_state=SpaFailureState.build(debug.get("spaFailureState")),
|
|
718
|
+
heater_error=debug.get("heaterError", "0"),
|
|
719
|
+
power_frequency=debug.get("powerFrequency", "0"),
|
|
720
|
+
pressure_switch_status=debug.get("pressureSwitchStatus", "0"),
|
|
721
|
+
l1_n_volts=debug.get("L1_N_Volts", "0"),
|
|
722
|
+
l2_n_volts=debug.get("L2_N_Volts", "0"),
|
|
723
|
+
heater_volts=debug.get("Heater_Volts", "0"),
|
|
724
|
+
jet3_volts=debug.get("jet3_Volts", "0"),
|
|
725
|
+
jet1_jet2_blower_power=debug.get("jet1_jet2_blowerPower", "0"),
|
|
726
|
+
small_loads_power=debug.get("smallLoadsPower", "0"),
|
|
727
|
+
heater_power=debug.get("heaterPower", "0"),
|
|
728
|
+
jet3_power=debug.get("jet3Power", "0"),
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _parse_temperature(value: str | float | None) -> float | None:
|
|
733
|
+
"""Parse a temperature value from the API.
|
|
734
|
+
|
|
735
|
+
The API returns temperatures in formats like " 97F", " 38C",
|
|
736
|
+
"100", or empty strings. This function strips whitespace and
|
|
737
|
+
unit suffixes before parsing.
|
|
738
|
+
|
|
739
|
+
Args:
|
|
740
|
+
----
|
|
741
|
+
value: The raw temperature value from the API.
|
|
742
|
+
|
|
743
|
+
Returns:
|
|
744
|
+
-------
|
|
745
|
+
The temperature as a float, or None if not available.
|
|
746
|
+
|
|
747
|
+
"""
|
|
748
|
+
if value is None:
|
|
749
|
+
return None
|
|
750
|
+
# Convert to string and strip whitespace
|
|
751
|
+
text = str(value).strip()
|
|
752
|
+
if not text:
|
|
753
|
+
return None
|
|
754
|
+
# Strip trailing unit suffix (F or C)
|
|
755
|
+
if text[-1] in ("F", "C"):
|
|
756
|
+
text = text[:-1].strip()
|
|
757
|
+
if not text:
|
|
758
|
+
return None
|
|
759
|
+
try:
|
|
760
|
+
return float(text)
|
|
761
|
+
except (ValueError, TypeError):
|
|
762
|
+
return None
|