python-hotspring 1.0.1__tar.gz → 1.2.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.0.1 → python_hotspring-1.2.0}/PKG-INFO +8 -1
- {python_hotspring-1.0.1 → python_hotspring-1.2.0}/README.md +7 -0
- {python_hotspring-1.0.1 → python_hotspring-1.2.0}/pyproject.toml +1 -1
- {python_hotspring-1.0.1 → python_hotspring-1.2.0}/src/hotspring/const.py +7 -7
- {python_hotspring-1.0.1 → python_hotspring-1.2.0}/src/hotspring/hotspring.py +12 -2
- {python_hotspring-1.0.1 → python_hotspring-1.2.0}/src/hotspring/models.py +129 -24
- {python_hotspring-1.0.1 → python_hotspring-1.2.0}/LICENSE +0 -0
- {python_hotspring-1.0.1 → python_hotspring-1.2.0}/src/hotspring/__init__.py +0 -0
- {python_hotspring-1.0.1 → python_hotspring-1.2.0}/src/hotspring/exceptions.py +0 -0
- {python_hotspring-1.0.1 → python_hotspring-1.2.0}/src/hotspring/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-hotspring
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Asynchronous Python client for Hot Spring Connected Spa Kit 2.
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -71,6 +71,13 @@ It is primarily designed to be used as the communication layer for an official
|
|
|
71
71
|
- **Energy saving schedules** — View configured energy saving time windows
|
|
72
72
|
- **Clean cycle** — Start or stop the 10-minute clean cycle
|
|
73
73
|
|
|
74
|
+
> [!CAUTION]
|
|
75
|
+
> The spa's energy consumption and usage metrics are not always accurate. Most
|
|
76
|
+
> values related to power (including current, amp, volt, etc) and values based
|
|
77
|
+
> on time (like the amount of time the jet have been turned on) are most likely
|
|
78
|
+
> incorrect, as I have not been able to fully reverse engineer these aspects of
|
|
79
|
+
> the API.
|
|
80
|
+
|
|
74
81
|
### Compatible Spas
|
|
75
82
|
|
|
76
83
|
This library works with any Hot Spring, Caldera, or Freeflow spa that supports
|
|
@@ -38,6 +38,13 @@ It is primarily designed to be used as the communication layer for an official
|
|
|
38
38
|
- **Energy saving schedules** — View configured energy saving time windows
|
|
39
39
|
- **Clean cycle** — Start or stop the 10-minute clean cycle
|
|
40
40
|
|
|
41
|
+
> [!CAUTION]
|
|
42
|
+
> The spa's energy consumption and usage metrics are not always accurate. Most
|
|
43
|
+
> values related to power (including current, amp, volt, etc) and values based
|
|
44
|
+
> on time (like the amount of time the jet have been turned on) are most likely
|
|
45
|
+
> incorrect, as I have not been able to fully reverse engineer these aspects of
|
|
46
|
+
> the API.
|
|
47
|
+
|
|
41
48
|
### Compatible Spas
|
|
42
49
|
|
|
43
50
|
This library works with any Hot Spring, Caldera, or Freeflow spa that supports
|
|
@@ -30,7 +30,7 @@ class HeatingMode(Enum):
|
|
|
30
30
|
Returns:
|
|
31
31
|
-------
|
|
32
32
|
The matching HeatingMode, or HeatingMode.UNKNOWN for
|
|
33
|
-
|
|
33
|
+
unrecognized values.
|
|
34
34
|
|
|
35
35
|
"""
|
|
36
36
|
if value is None:
|
|
@@ -64,7 +64,7 @@ class JetSpeed(Enum):
|
|
|
64
64
|
Returns:
|
|
65
65
|
-------
|
|
66
66
|
The matching JetSpeed, or JetSpeed.UNKNOWN for
|
|
67
|
-
|
|
67
|
+
unrecognized values.
|
|
68
68
|
|
|
69
69
|
"""
|
|
70
70
|
if value is None:
|
|
@@ -105,7 +105,7 @@ class LightColor(Enum):
|
|
|
105
105
|
Returns:
|
|
106
106
|
-------
|
|
107
107
|
The matching LightColor, or LightColor.UNKNOWN for
|
|
108
|
-
|
|
108
|
+
unrecognized values.
|
|
109
109
|
|
|
110
110
|
"""
|
|
111
111
|
if value is None:
|
|
@@ -136,7 +136,7 @@ class LightWheelMode(Enum):
|
|
|
136
136
|
Returns:
|
|
137
137
|
-------
|
|
138
138
|
The matching LightWheelMode, or LightWheelMode.UNKNOWN for
|
|
139
|
-
|
|
139
|
+
unrecognized values.
|
|
140
140
|
|
|
141
141
|
"""
|
|
142
142
|
if value is None:
|
|
@@ -169,7 +169,7 @@ class BrightnessLevel(Enum):
|
|
|
169
169
|
Returns:
|
|
170
170
|
-------
|
|
171
171
|
The matching BrightnessLevel, or BrightnessLevel.UNKNOWN for
|
|
172
|
-
|
|
172
|
+
unrecognized values.
|
|
173
173
|
|
|
174
174
|
"""
|
|
175
175
|
if value is None:
|
|
@@ -198,7 +198,7 @@ class TemperatureUnit(Enum):
|
|
|
198
198
|
Returns:
|
|
199
199
|
-------
|
|
200
200
|
The matching TemperatureUnit, or TemperatureUnit.UNKNOWN for
|
|
201
|
-
|
|
201
|
+
unrecognized values.
|
|
202
202
|
|
|
203
203
|
"""
|
|
204
204
|
if value is None:
|
|
@@ -226,7 +226,7 @@ class SpaFailureState(Enum):
|
|
|
226
226
|
Returns:
|
|
227
227
|
-------
|
|
228
228
|
The matching SpaFailureState, or SpaFailureState.UNKNOWN for
|
|
229
|
-
|
|
229
|
+
unrecognized values.
|
|
230
230
|
|
|
231
231
|
"""
|
|
232
232
|
if value is None:
|
|
@@ -169,11 +169,21 @@ class HotSpring:
|
|
|
169
169
|
self.spa.update_from_dict(status_data)
|
|
170
170
|
|
|
171
171
|
# Fetch identity/startup info
|
|
172
|
+
identity_data: dict[str, object] = {}
|
|
172
173
|
try:
|
|
173
174
|
startup_data = await self.request("/startup")
|
|
174
|
-
|
|
175
|
+
identity_data.update(startup_data)
|
|
175
176
|
except HotSpringError:
|
|
176
|
-
pass
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
model_data = await self.request("/spamodel")
|
|
181
|
+
identity_data.update(model_data)
|
|
182
|
+
except HotSpringError:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
if identity_data:
|
|
186
|
+
self.spa.update_info(identity_data)
|
|
177
187
|
|
|
178
188
|
# Fetch connection status
|
|
179
189
|
try:
|
|
@@ -42,6 +42,7 @@ class Spa:
|
|
|
42
42
|
versions: Versions
|
|
43
43
|
connection_status: ConnectionStatus
|
|
44
44
|
diagnostics: Diagnostics
|
|
45
|
+
test_metrics: SpaTestData
|
|
45
46
|
|
|
46
47
|
def __init__(self, data: dict[str, object]) -> None:
|
|
47
48
|
"""Initialize a Spa from the full API response.
|
|
@@ -74,6 +75,7 @@ class Spa:
|
|
|
74
75
|
self.spa_lock = SpaLock.from_dict(data.get("spaLock", {}))
|
|
75
76
|
self.water_care = WaterCare.from_dict(data.get("waterCare", {}))
|
|
76
77
|
self.freshwater_iq = FreshWaterIQ.from_dict(data.get("FWIQ_Parameters", {}))
|
|
78
|
+
self.test_metrics = SpaTestData.from_dict(data.get("test_data", {}))
|
|
77
79
|
self.energy_savings = EnergySaving.list_from_dict(data.get("energySavings", {}))
|
|
78
80
|
self.versions = Versions.from_dict(
|
|
79
81
|
data.get("productVersions", {}).get("status", {})
|
|
@@ -85,6 +87,8 @@ class Spa:
|
|
|
85
87
|
self.connection_status = ConnectionStatus.from_dict({})
|
|
86
88
|
if not hasattr(self, "diagnostics"):
|
|
87
89
|
self.diagnostics = Diagnostics.from_dict({})
|
|
90
|
+
if not hasattr(self, "test_metrics"):
|
|
91
|
+
self.test_metrics = SpaTestData.from_dict({})
|
|
88
92
|
|
|
89
93
|
return self
|
|
90
94
|
|
|
@@ -96,7 +100,31 @@ class Spa:
|
|
|
96
100
|
data: Combined data from /startup and /spamodel endpoints.
|
|
97
101
|
|
|
98
102
|
"""
|
|
99
|
-
|
|
103
|
+
if not hasattr(self, "info"):
|
|
104
|
+
self.info = SpaInfo.from_dict(data)
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
# Update existing info fields if present in data
|
|
108
|
+
if "HOSTNAME" in data:
|
|
109
|
+
self.info.hostname = str(data["HOSTNAME"])
|
|
110
|
+
if "rootTopic" in data:
|
|
111
|
+
self.info.root_topic = str(data["rootTopic"])
|
|
112
|
+
if "SNAready" in data:
|
|
113
|
+
self.info.sna_ready = data["SNAready"] in ("Ready", "Yes")
|
|
114
|
+
|
|
115
|
+
if "SPAModelData" in data:
|
|
116
|
+
model_data = data["SPAModelData"]
|
|
117
|
+
if isinstance(model_data, dict):
|
|
118
|
+
status = model_data.get("status", {})
|
|
119
|
+
if isinstance(status, dict):
|
|
120
|
+
if "brandName" in status:
|
|
121
|
+
self.info.brand_name = str(status["brandName"])
|
|
122
|
+
if "collectionType" in status:
|
|
123
|
+
self.info.collection_type = str(status["collectionType"])
|
|
124
|
+
if "modelType" in status:
|
|
125
|
+
self.info.model_type = str(status["modelType"])
|
|
126
|
+
if "volume" in status:
|
|
127
|
+
self.info.volume = int(status["volume"] or 0)
|
|
100
128
|
|
|
101
129
|
def update_connection_status(self, data: dict[str, object]) -> None:
|
|
102
130
|
"""Update connection status from /spaConnectStatus response.
|
|
@@ -137,10 +165,12 @@ class SpaInfo:
|
|
|
137
165
|
"""
|
|
138
166
|
|
|
139
167
|
hostname: str
|
|
140
|
-
|
|
141
|
-
model: str
|
|
142
|
-
ssid: str
|
|
168
|
+
root_topic: str
|
|
143
169
|
sna_ready: bool
|
|
170
|
+
brand_name: str
|
|
171
|
+
collection_type: str
|
|
172
|
+
model_type: str
|
|
173
|
+
volume: int
|
|
144
174
|
|
|
145
175
|
@staticmethod
|
|
146
176
|
def from_dict(data: dict[str, object]) -> SpaInfo:
|
|
@@ -155,14 +185,49 @@ class SpaInfo:
|
|
|
155
185
|
A SpaInfo instance.
|
|
156
186
|
|
|
157
187
|
"""
|
|
188
|
+
model_status: dict[str, object] = {}
|
|
189
|
+
model_data = data.get("SPAModelData")
|
|
190
|
+
if isinstance(model_data, dict):
|
|
191
|
+
status = model_data.get("status")
|
|
192
|
+
if isinstance(status, dict):
|
|
193
|
+
model_status = status
|
|
194
|
+
|
|
158
195
|
return SpaInfo(
|
|
159
|
-
hostname=data.get("HOSTNAME", ""),
|
|
160
|
-
|
|
161
|
-
model=data.get("model", ""),
|
|
162
|
-
ssid=data.get("SSID", ""),
|
|
196
|
+
hostname=str(data.get("HOSTNAME", "")),
|
|
197
|
+
root_topic=str(data.get("rootTopic", "")),
|
|
163
198
|
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", "")),
|
|
202
|
+
volume=int(model_status.get("volume") or 0),
|
|
164
203
|
)
|
|
165
204
|
|
|
205
|
+
@property
|
|
206
|
+
def mac_address(self) -> str:
|
|
207
|
+
"""Derive the MAC address from root_topic.
|
|
208
|
+
|
|
209
|
+
The HNA firmware builds root_topic as ``mySpa%02X%02X%02X%02X%02X%02X``
|
|
210
|
+
using the device's 6-byte WiFi MAC, so the 12 hex characters after the
|
|
211
|
+
``mySpa`` prefix are the full MAC address.
|
|
212
|
+
|
|
213
|
+
Returns
|
|
214
|
+
-------
|
|
215
|
+
Colon-separated uppercase MAC (e.g. ``"AA:BB:CC:11:22:33"``),
|
|
216
|
+
or ``""`` if root_topic does not match the expected format.
|
|
217
|
+
|
|
218
|
+
"""
|
|
219
|
+
prefix = "mySpa"
|
|
220
|
+
if not self.root_topic.startswith(prefix):
|
|
221
|
+
return ""
|
|
222
|
+
mac_hex = self.root_topic[len(prefix) :]
|
|
223
|
+
try:
|
|
224
|
+
mac_bytes = bytes.fromhex(mac_hex)
|
|
225
|
+
except ValueError:
|
|
226
|
+
return ""
|
|
227
|
+
if len(mac_bytes) != 6:
|
|
228
|
+
return ""
|
|
229
|
+
return ":".join(f"{b:02X}" for b in mac_bytes)
|
|
230
|
+
|
|
166
231
|
|
|
167
232
|
@dataclass
|
|
168
233
|
class Heater: # pylint: disable=too-many-instance-attributes
|
|
@@ -172,8 +237,8 @@ class Heater: # pylint: disable=too-many-instance-attributes
|
|
|
172
237
|
heater_lock: bool
|
|
173
238
|
heatpump_installed: bool
|
|
174
239
|
heating_mode: HeatingMode
|
|
175
|
-
heater_current:
|
|
176
|
-
|
|
240
|
+
heater_current: float
|
|
241
|
+
heater_on_seconds: int
|
|
177
242
|
set_temperature: float | None
|
|
178
243
|
current_temperature: float | None
|
|
179
244
|
temperature_unit: TemperatureUnit
|
|
@@ -198,8 +263,8 @@ class Heater: # pylint: disable=too-many-instance-attributes
|
|
|
198
263
|
heatpump_installed=status.get("heatpumpInstalled", "notinstalled")
|
|
199
264
|
!= "notinstalled",
|
|
200
265
|
heating_mode=HeatingMode.build(status.get("heatingMode")),
|
|
201
|
-
heater_current=int(status.get("heaterCurrent", 0)),
|
|
202
|
-
|
|
266
|
+
heater_current=int(status.get("heaterCurrent", 0)) / 2560.0,
|
|
267
|
+
heater_on_seconds=int(status.get("heaterHours", 0)) // 256,
|
|
203
268
|
set_temperature=_parse_temperature(status.get("setWaterTemperature")),
|
|
204
269
|
current_temperature=_parse_temperature(
|
|
205
270
|
status.get("currentWaterTemperature")
|
|
@@ -240,7 +305,7 @@ class Jet:
|
|
|
240
305
|
|
|
241
306
|
# Find the on_seconds key dynamically (e.g., jet_1_ON_sec)
|
|
242
307
|
on_sec_key = f"jet_{jet_id}_ON_sec"
|
|
243
|
-
on_seconds = int(status.get(on_sec_key, 0))
|
|
308
|
+
on_seconds = int(status.get(on_sec_key, 0)) // 256
|
|
244
309
|
|
|
245
310
|
return Jet(
|
|
246
311
|
jet_id=jet_id,
|
|
@@ -462,7 +527,6 @@ class WaterCare: # pylint: disable=too-many-instance-attributes
|
|
|
462
527
|
system_enabled: bool
|
|
463
528
|
ace_mode: str
|
|
464
529
|
boost_active: bool
|
|
465
|
-
salt_level: str
|
|
466
530
|
salt_value: int
|
|
467
531
|
|
|
468
532
|
@staticmethod
|
|
@@ -486,9 +550,8 @@ class WaterCare: # pylint: disable=too-many-instance-attributes
|
|
|
486
550
|
one_twenty_day_timer=int(status.get("120DayTimer", 0)),
|
|
487
551
|
level=int(status.get("level", 0)),
|
|
488
552
|
system_enabled=status.get("SystemEnable", "disable") == "enable",
|
|
489
|
-
ace_mode=status.get("AceMode", "inactive"),
|
|
553
|
+
ace_mode=str(status.get("AceMode", "inactive")),
|
|
490
554
|
boost_active=status.get("boost", "inactive") != "inactive",
|
|
491
|
-
salt_level=status.get("saltLevel", ""),
|
|
492
555
|
salt_value=int(status.get("saltValue", 0)),
|
|
493
556
|
)
|
|
494
557
|
|
|
@@ -676,6 +739,45 @@ class ConnectionStatus:
|
|
|
676
739
|
)
|
|
677
740
|
|
|
678
741
|
|
|
742
|
+
@dataclass
|
|
743
|
+
class SpaTestData:
|
|
744
|
+
"""Test data metrics from the spa, including raw current readings."""
|
|
745
|
+
|
|
746
|
+
heater_test_status: str
|
|
747
|
+
temp_offset: float
|
|
748
|
+
vsense_cal: float
|
|
749
|
+
jet1_jet2_blower_current: float
|
|
750
|
+
small_loads_current: float
|
|
751
|
+
heater_current: float
|
|
752
|
+
jet3_current: float
|
|
753
|
+
|
|
754
|
+
@staticmethod
|
|
755
|
+
def from_dict(data: dict[str, object]) -> SpaTestData:
|
|
756
|
+
"""Create a SpaTestData from API response data.
|
|
757
|
+
|
|
758
|
+
Args:
|
|
759
|
+
----
|
|
760
|
+
data: The ``test_data`` dict from the /status response.
|
|
761
|
+
|
|
762
|
+
Returns:
|
|
763
|
+
-------
|
|
764
|
+
A SpaTestData instance.
|
|
765
|
+
|
|
766
|
+
"""
|
|
767
|
+
status = data.get("status", {})
|
|
768
|
+
return SpaTestData(
|
|
769
|
+
heater_test_status=str(status.get("heaterTestStatus", "off")),
|
|
770
|
+
temp_offset=float(status.get("tempOffset", 0.0)),
|
|
771
|
+
vsense_cal=float(status.get("VsenseCal", 0.0)),
|
|
772
|
+
jet1_jet2_blower_current=(
|
|
773
|
+
int(status.get("jet1+jet2+blowerCurrent", 0)) / 2560.0
|
|
774
|
+
),
|
|
775
|
+
small_loads_current=int(status.get("smallLoadsCurrent", 0)) / 2560.0,
|
|
776
|
+
heater_current=int(status.get("heaterCurrent", 0)) / 2560.0,
|
|
777
|
+
jet3_current=int(status.get("jet3Current", 0)) / 2560.0,
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
|
|
679
781
|
@dataclass
|
|
680
782
|
class Diagnostics: # pylint: disable=too-many-instance-attributes
|
|
681
783
|
"""Diagnostic and power metrics from the spa.
|
|
@@ -689,10 +791,10 @@ class Diagnostics: # pylint: disable=too-many-instance-attributes
|
|
|
689
791
|
heater_error: str
|
|
690
792
|
power_frequency: str
|
|
691
793
|
pressure_switch_status: str
|
|
692
|
-
l1_n_volts:
|
|
693
|
-
l2_n_volts:
|
|
694
|
-
heater_volts:
|
|
695
|
-
jet3_volts:
|
|
794
|
+
l1_n_volts: float
|
|
795
|
+
l2_n_volts: float
|
|
796
|
+
heater_volts: float
|
|
797
|
+
jet3_volts: float
|
|
696
798
|
jet1_jet2_blower_power: str
|
|
697
799
|
small_loads_power: str
|
|
698
800
|
heater_power: str
|
|
@@ -718,10 +820,13 @@ class Diagnostics: # pylint: disable=too-many-instance-attributes
|
|
|
718
820
|
heater_error=debug.get("heaterError", "0"),
|
|
719
821
|
power_frequency=debug.get("powerFrequency", "0"),
|
|
720
822
|
pressure_switch_status=debug.get("pressureSwitchStatus", "0"),
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
823
|
+
# NOTE: The /32 scaling for volts is likely incorrect for all models or
|
|
824
|
+
# specific configurations, as real-world readings (e.g., 56V on L2)
|
|
825
|
+
# do not match expectations. This needs further investigation.
|
|
826
|
+
l1_n_volts=int(debug.get("L1_N_Volts") or 0) / 32.0,
|
|
827
|
+
l2_n_volts=int(debug.get("L2_N_Volts") or 0) / 32.0,
|
|
828
|
+
heater_volts=int(debug.get("Heater_Volts") or 0) / 32.0,
|
|
829
|
+
jet3_volts=int(debug.get("jet3_Volts") or 0) / 32.0,
|
|
725
830
|
jet1_jet2_blower_power=debug.get("jet1_jet2_blowerPower", "0"),
|
|
726
831
|
small_loads_power=debug.get("smallLoadsPower", "0"),
|
|
727
832
|
heater_power=debug.get("heaterPower", "0"),
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|