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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hotspring
3
- Version: 1.0.0
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.0.0"
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
- 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,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
- 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
 
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: int
176
- heater_hours: int
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
- heater_hours=int(status.get("heaterHours", 0)),
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: str
693
- l2_n_volts: str
694
- heater_volts: str
695
- jet3_volts: str
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
- 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"),
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"),