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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hotspring
3
- Version: 1.0.1
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
@@ -22,7 +22,7 @@ packages = [
22
22
  ]
23
23
  readme = "README.md"
24
24
  repository = "https://github.com/Moustachauve/python-hotspring"
25
- version = "1.0.1"
25
+ version = "1.2.0"
26
26
 
27
27
  [tool.poetry.dependencies]
28
28
  aiohttp = ">=3.0.0"
@@ -30,7 +30,7 @@ class HeatingMode(Enum):
30
30
  Returns:
31
31
  -------
32
32
  The matching HeatingMode, or HeatingMode.UNKNOWN for
33
- unrecognised values.
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
- unrecognised values.
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
- unrecognised values.
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
- unrecognised values.
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
- unrecognised values.
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
- unrecognised values.
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
- unrecognised values.
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
- self.spa.update_info(startup_data)
175
+ identity_data.update(startup_data)
175
176
  except HotSpringError:
176
- pass # Non-critical; identity may already be populated
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
- self.info = SpaInfo.from_dict(data)
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
- mac_address: str
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
- mac_address=data.get("MAC", ""),
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: int
176
- heater_hours: int
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
- heater_hours=int(status.get("heaterHours", 0)),
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: str
693
- l2_n_volts: str
694
- heater_volts: str
695
- jet3_volts: str
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
- 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"),
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"),