python-hotspring 1.0.0__tar.gz → 1.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-1.0.0 → python_hotspring-1.1.0}/PKG-INFO +9 -2
- {python_hotspring-1.0.0 → python_hotspring-1.1.0}/README.md +7 -0
- {python_hotspring-1.0.0 → python_hotspring-1.1.0}/pyproject.toml +2 -2
- {python_hotspring-1.0.0 → python_hotspring-1.1.0}/src/hotspring/const.py +7 -7
- {python_hotspring-1.0.0 → python_hotspring-1.1.0}/src/hotspring/hotspring.py +12 -2
- {python_hotspring-1.0.0 → python_hotspring-1.1.0}/src/hotspring/models.py +103 -24
- {python_hotspring-1.0.0 → python_hotspring-1.1.0}/LICENSE +0 -0
- {python_hotspring-1.0.0 → python_hotspring-1.1.0}/src/hotspring/__init__.py +0 -0
- {python_hotspring-1.0.0 → python_hotspring-1.1.0}/src/hotspring/exceptions.py +0 -0
- {python_hotspring-1.0.0 → python_hotspring-1.1.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.
|
|
3
|
+
Version: 1.1.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 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
22
22
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
23
|
Requires-Dist: aiohttp (>=3.0.0)
|
|
24
24
|
Requires-Dist: awesomeversion (>=22.1.0)
|
|
25
|
-
Requires-Dist: backoff (>=2.2.0)
|
|
25
|
+
Requires-Dist: python-backoff (>=2.2.0)
|
|
26
26
|
Requires-Dist: yarl (>=1.6.0)
|
|
27
27
|
Project-URL: Bug Tracker, https://github.com/Moustachauve/python-hotspring/issues
|
|
28
28
|
Project-URL: Changelog, https://github.com/Moustachauve/python-hotspring/releases
|
|
@@ -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
|
|
@@ -22,12 +22,12 @@ packages = [
|
|
|
22
22
|
]
|
|
23
23
|
readme = "README.md"
|
|
24
24
|
repository = "https://github.com/Moustachauve/python-hotspring"
|
|
25
|
-
version = "1.
|
|
25
|
+
version = "1.1.0"
|
|
26
26
|
|
|
27
27
|
[tool.poetry.dependencies]
|
|
28
28
|
aiohttp = ">=3.0.0"
|
|
29
29
|
awesomeversion = ">=22.1.0"
|
|
30
|
-
backoff = ">=2.2.0"
|
|
30
|
+
python-backoff = ">=2.2.0"
|
|
31
31
|
python = "^3.12"
|
|
32
32
|
yarl = ">=1.6.0"
|
|
33
33
|
|
|
@@ -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,12 +185,21 @@ 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
|
|
|
166
205
|
|
|
@@ -172,8 +211,8 @@ class Heater: # pylint: disable=too-many-instance-attributes
|
|
|
172
211
|
heater_lock: bool
|
|
173
212
|
heatpump_installed: bool
|
|
174
213
|
heating_mode: HeatingMode
|
|
175
|
-
heater_current:
|
|
176
|
-
|
|
214
|
+
heater_current: float
|
|
215
|
+
heater_on_seconds: int
|
|
177
216
|
set_temperature: float | None
|
|
178
217
|
current_temperature: float | None
|
|
179
218
|
temperature_unit: TemperatureUnit
|
|
@@ -198,8 +237,8 @@ class Heater: # pylint: disable=too-many-instance-attributes
|
|
|
198
237
|
heatpump_installed=status.get("heatpumpInstalled", "notinstalled")
|
|
199
238
|
!= "notinstalled",
|
|
200
239
|
heating_mode=HeatingMode.build(status.get("heatingMode")),
|
|
201
|
-
heater_current=int(status.get("heaterCurrent", 0)),
|
|
202
|
-
|
|
240
|
+
heater_current=int(status.get("heaterCurrent", 0)) / 2560.0,
|
|
241
|
+
heater_on_seconds=int(status.get("heaterHours", 0)) // 256,
|
|
203
242
|
set_temperature=_parse_temperature(status.get("setWaterTemperature")),
|
|
204
243
|
current_temperature=_parse_temperature(
|
|
205
244
|
status.get("currentWaterTemperature")
|
|
@@ -240,7 +279,7 @@ class Jet:
|
|
|
240
279
|
|
|
241
280
|
# Find the on_seconds key dynamically (e.g., jet_1_ON_sec)
|
|
242
281
|
on_sec_key = f"jet_{jet_id}_ON_sec"
|
|
243
|
-
on_seconds = int(status.get(on_sec_key, 0))
|
|
282
|
+
on_seconds = int(status.get(on_sec_key, 0)) // 256
|
|
244
283
|
|
|
245
284
|
return Jet(
|
|
246
285
|
jet_id=jet_id,
|
|
@@ -462,7 +501,6 @@ class WaterCare: # pylint: disable=too-many-instance-attributes
|
|
|
462
501
|
system_enabled: bool
|
|
463
502
|
ace_mode: str
|
|
464
503
|
boost_active: bool
|
|
465
|
-
salt_level: str
|
|
466
504
|
salt_value: int
|
|
467
505
|
|
|
468
506
|
@staticmethod
|
|
@@ -486,9 +524,8 @@ class WaterCare: # pylint: disable=too-many-instance-attributes
|
|
|
486
524
|
one_twenty_day_timer=int(status.get("120DayTimer", 0)),
|
|
487
525
|
level=int(status.get("level", 0)),
|
|
488
526
|
system_enabled=status.get("SystemEnable", "disable") == "enable",
|
|
489
|
-
ace_mode=status.get("AceMode", "inactive"),
|
|
527
|
+
ace_mode=str(status.get("AceMode", "inactive")),
|
|
490
528
|
boost_active=status.get("boost", "inactive") != "inactive",
|
|
491
|
-
salt_level=status.get("saltLevel", ""),
|
|
492
529
|
salt_value=int(status.get("saltValue", 0)),
|
|
493
530
|
)
|
|
494
531
|
|
|
@@ -676,6 +713,45 @@ class ConnectionStatus:
|
|
|
676
713
|
)
|
|
677
714
|
|
|
678
715
|
|
|
716
|
+
@dataclass
|
|
717
|
+
class SpaTestData:
|
|
718
|
+
"""Test data metrics from the spa, including raw current readings."""
|
|
719
|
+
|
|
720
|
+
heater_test_status: str
|
|
721
|
+
temp_offset: float
|
|
722
|
+
vsense_cal: float
|
|
723
|
+
jet1_jet2_blower_current: float
|
|
724
|
+
small_loads_current: float
|
|
725
|
+
heater_current: float
|
|
726
|
+
jet3_current: float
|
|
727
|
+
|
|
728
|
+
@staticmethod
|
|
729
|
+
def from_dict(data: dict[str, object]) -> SpaTestData:
|
|
730
|
+
"""Create a SpaTestData from API response data.
|
|
731
|
+
|
|
732
|
+
Args:
|
|
733
|
+
----
|
|
734
|
+
data: The ``test_data`` dict from the /status response.
|
|
735
|
+
|
|
736
|
+
Returns:
|
|
737
|
+
-------
|
|
738
|
+
A SpaTestData instance.
|
|
739
|
+
|
|
740
|
+
"""
|
|
741
|
+
status = data.get("status", {})
|
|
742
|
+
return SpaTestData(
|
|
743
|
+
heater_test_status=str(status.get("heaterTestStatus", "off")),
|
|
744
|
+
temp_offset=float(status.get("tempOffset", 0.0)),
|
|
745
|
+
vsense_cal=float(status.get("VsenseCal", 0.0)),
|
|
746
|
+
jet1_jet2_blower_current=(
|
|
747
|
+
int(status.get("jet1+jet2+blowerCurrent", 0)) / 2560.0
|
|
748
|
+
),
|
|
749
|
+
small_loads_current=int(status.get("smallLoadsCurrent", 0)) / 2560.0,
|
|
750
|
+
heater_current=int(status.get("heaterCurrent", 0)) / 2560.0,
|
|
751
|
+
jet3_current=int(status.get("jet3Current", 0)) / 2560.0,
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
|
|
679
755
|
@dataclass
|
|
680
756
|
class Diagnostics: # pylint: disable=too-many-instance-attributes
|
|
681
757
|
"""Diagnostic and power metrics from the spa.
|
|
@@ -689,10 +765,10 @@ class Diagnostics: # pylint: disable=too-many-instance-attributes
|
|
|
689
765
|
heater_error: str
|
|
690
766
|
power_frequency: str
|
|
691
767
|
pressure_switch_status: str
|
|
692
|
-
l1_n_volts:
|
|
693
|
-
l2_n_volts:
|
|
694
|
-
heater_volts:
|
|
695
|
-
jet3_volts:
|
|
768
|
+
l1_n_volts: float
|
|
769
|
+
l2_n_volts: float
|
|
770
|
+
heater_volts: float
|
|
771
|
+
jet3_volts: float
|
|
696
772
|
jet1_jet2_blower_power: str
|
|
697
773
|
small_loads_power: str
|
|
698
774
|
heater_power: str
|
|
@@ -718,10 +794,13 @@ class Diagnostics: # pylint: disable=too-many-instance-attributes
|
|
|
718
794
|
heater_error=debug.get("heaterError", "0"),
|
|
719
795
|
power_frequency=debug.get("powerFrequency", "0"),
|
|
720
796
|
pressure_switch_status=debug.get("pressureSwitchStatus", "0"),
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
797
|
+
# NOTE: The /32 scaling for volts is likely incorrect for all models or
|
|
798
|
+
# specific configurations, as real-world readings (e.g., 56V on L2)
|
|
799
|
+
# do not match expectations. This needs further investigation.
|
|
800
|
+
l1_n_volts=int(debug.get("L1_N_Volts") or 0) / 32.0,
|
|
801
|
+
l2_n_volts=int(debug.get("L2_N_Volts") or 0) / 32.0,
|
|
802
|
+
heater_volts=int(debug.get("Heater_Volts") or 0) / 32.0,
|
|
803
|
+
jet3_volts=int(debug.get("jet3_Volts") or 0) / 32.0,
|
|
725
804
|
jet1_jet2_blower_power=debug.get("jet1_jet2_blowerPower", "0"),
|
|
726
805
|
small_loads_power=debug.get("smallLoadsPower", "0"),
|
|
727
806
|
heater_power=debug.get("heaterPower", "0"),
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|