Skip to content

Commit 56ef795

Browse files
authored
Wait for state changes to propagate before returning (#56)
1 parent ccd5c3c commit 56ef795

7 files changed

Lines changed: 427 additions & 16 deletions

File tree

.flake8

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
[flake8]
2-
ignore = E501,E252
2+
ignore = E501,E252,W503
33
exclude = venv,.tox

.pre-commit-config.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
repos:
22
- repo: https://github.com/pre-commit/pre-commit-hooks
3-
rev: v2.3.0
3+
rev: v5.0.0
44
hooks:
55
- id: end-of-file-fixer
66
- id: trailing-whitespace
77
- id: check-yaml
88
- repo: https://github.com/psf/black
9-
rev: 23.10.1
9+
rev: 25.1.0
1010
hooks:
1111
- id: black
1212
args:
1313
- --safe
1414
- --quiet
1515
files: ^((smarttub|tests)/.+)?[^/]+\.py$
1616
- repo: https://github.com/pycqa/flake8
17-
rev: 3.8.3
17+
rev: 7.3.0
1818
hooks:
1919
- id: flake8
2020
- repo: local

pytest.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
[pytest]
22
asyncio_mode = auto
3+
markers =
4+
integration: mark a test as an integration test.

smarttub/api.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,36 @@ def __init__(self, api: SmartTub, account: Account, **properties):
179179
async def request(self, method, resource: str, body=None):
180180
return await self._api.request(method, f"spas/{self.id}/{resource}", body)
181181

182+
async def _wait_for_state_change(
183+
self, check_func, timeout=10, get_status_method=None
184+
):
185+
"""Wait for a state change to be reflected in the API.
186+
187+
Args:
188+
check_func: A function that takes a SpaState and returns True if the desired state is reached
189+
timeout: Maximum time to wait in seconds
190+
get_status_method: A method to call to get the current state if needed
191+
192+
Returns:
193+
The final SpaState after the change is complete
194+
195+
Raises:
196+
RuntimeError if the state change is not reflected within the timeout period
197+
"""
198+
start_time = datetime.datetime.now().timestamp()
199+
while True:
200+
state = await self.get_status()
201+
if check_func(state):
202+
return state
203+
204+
if datetime.datetime.now().timestamp() - start_time > timeout:
205+
raise RuntimeError("State change not reflected within timeout period")
206+
207+
await asyncio.sleep(0.5)
208+
209+
if get_status_method:
210+
state = await get_status_method()
211+
182212
async def get_status(self) -> "SpaState":
183213
"""Query the status of the spa."""
184214
return SpaState(self, **await self.request("GET", "status"))
@@ -236,20 +266,28 @@ async def get_energy_usage(
236266
async def set_heat_mode(self, mode: HeatMode):
237267
body = {"heatMode": mode.name}
238268
await self.request("PATCH", "config", body)
269+
await self._wait_for_state_change(lambda state: state.heat_mode == mode)
239270

240271
async def set_temperature(self, temp_c: float):
241272
body = {
242273
# responds with 500 if given more than 1 decimal point
243274
"setTemperature": round(temp_c, 1)
244275
}
245276
await self.request("PATCH", "config", body)
277+
await self._wait_for_state_change(
278+
lambda state: state.set_temperature == round(temp_c, 1)
279+
)
246280

247281
async def toggle_clearray(self):
248282
await self.request("POST", "clearray/toggle")
283+
# No need to wait for state change as this is a toggle operation
249284

250285
async def set_temperature_format(self, temperature_format: TemperatureFormat):
251286
body = {"displayTemperatureFormat": temperature_format.name}
252287
await self.request("POST", "config", body)
288+
await self._wait_for_state_change(
289+
lambda state: state.display_temperature_format == temperature_format.name
290+
)
253291

254292
async def set_date_time(
255293
self, date: datetime.date = None, time: datetime.time = None
@@ -265,6 +303,7 @@ async def set_date_time(
265303
config["time"] = time.isoformat("minutes")
266304
body = {"dateTimeConfig": config}
267305
await self.request("POST", "config", body)
306+
# No need to wait for state change as this is a one-time operation
268307

269308
def __str__(self):
270309
return f"<Spa {self.id}>"
@@ -445,7 +484,17 @@ def __init__(self, spa: Spa, **properties):
445484
self.properties = properties
446485

447486
async def toggle(self):
487+
# For toggle, we need to wait for the state to change from its current state
488+
current_state = self.state
448489
await self.spa.request("POST", f"pumps/{self.id}/toggle")
490+
await self.spa._wait_for_state_change(
491+
lambda state: any(
492+
pump.state != current_state
493+
for pump in state.pumps
494+
if pump.id == self.id
495+
),
496+
get_status_method=self.spa.get_status_full,
497+
)
449498

450499
def __str__(self):
451500
return f"<SpaPump {self.id}: {self.type.name}={self.state.name}>"
@@ -479,6 +528,14 @@ async def set_mode(self, mode: LightMode, intensity: int):
479528
"mode": mode.name,
480529
}
481530
await self.spa.request("PATCH", f"lights/{self.zone}", body)
531+
await self.spa._wait_for_state_change(
532+
lambda state: any(
533+
light.mode == mode and light.intensity == intensity
534+
for light in state.lights
535+
if light.zone == self.zone
536+
),
537+
get_status_method=self.spa.get_status_full,
538+
)
482539

483540
async def turn_off(self):
484541
await self.set_mode(self.LightMode.OFF, 0)

tests/integration/test_real_api.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import os
2+
import asyncio
3+
import pytest
4+
import aiohttp
5+
from smarttub import SmartTub, Spa
6+
7+
pytestmark = pytest.mark.integration
8+
9+
USERNAME = os.environ.get("SMARTTUB_USER")
10+
PASSWORD = os.environ.get("SMARTTUB_PASS")
11+
SPA_INDEX = int(os.environ.get("SMARTTUB_SPA_INDEX", "0"))
12+
POLL_INTERVAL = 2
13+
TIMEOUT = 60
14+
15+
16+
@pytest.mark.asyncio
17+
@pytest.mark.skipif(
18+
not USERNAME or not PASSWORD, reason="SMARTTUB_USER and SMARTTUB_PASS must be set"
19+
)
20+
async def test_real_api_polling():
21+
async with aiohttp.ClientSession() as session:
22+
st = SmartTub(session)
23+
await st.login(USERNAME, PASSWORD)
24+
account = await st.get_account()
25+
spas = await account.get_spas()
26+
spa = spas[SPA_INDEX]
27+
28+
# Save original state
29+
orig_temp = (await spa.get_status()).set_temperature
30+
orig_heat_mode = (await spa.get_status()).heat_mode
31+
orig_temp_format = (await spa.get_status()).display_temperature_format
32+
orig_pump_state = None
33+
orig_light_mode = None
34+
orig_light_intensity = None
35+
unreverted = {}
36+
37+
# 1. Test set_temperature (pick a value different from current)
38+
# Use valid setpoints: 98°F and 100°F (converted to Celsius)
39+
VALID_SETPOINTS = [round((f - 32) * 5 / 9, 1) for f in (98, 100)]
40+
test_temp = next(
41+
(t for t in VALID_SETPOINTS if round(t, 1) != round(orig_temp, 1)), None
42+
)
43+
if test_temp is not None:
44+
try:
45+
await spa.set_temperature(test_temp)
46+
await _wait_for(
47+
lambda: spa.get_status(),
48+
lambda s: round(s.set_temperature, 1) == round(test_temp, 1),
49+
)
50+
except Exception as e:
51+
print(f"WARNING: Could not set temperature to {test_temp}: {e}")
52+
# Restore
53+
try:
54+
await spa.set_temperature(orig_temp)
55+
await _wait_for(
56+
lambda: spa.get_status(),
57+
lambda s: round(s.set_temperature, 1) == round(orig_temp, 1),
58+
)
59+
except Exception as e:
60+
unreverted["set_temperature"] = {
61+
"expected": orig_temp,
62+
"current": (await spa.get_status()).set_temperature,
63+
"error": str(e),
64+
}
65+
66+
# 2. Test set_heat_mode (toggle)
67+
new_heat_mode = (
68+
Spa.HeatMode.ECONOMY
69+
if orig_heat_mode != Spa.HeatMode.ECONOMY
70+
else Spa.HeatMode.AUTO
71+
)
72+
try:
73+
await spa.set_heat_mode(new_heat_mode)
74+
await _wait_for(
75+
lambda: spa.get_status(), lambda s: s.heat_mode == new_heat_mode
76+
)
77+
except Exception as e:
78+
print(f"WARNING: Could not set heat mode to {new_heat_mode}: {e}")
79+
# Restore
80+
try:
81+
await spa.set_heat_mode(orig_heat_mode)
82+
await _wait_for(
83+
lambda: spa.get_status(), lambda s: s.heat_mode == orig_heat_mode
84+
)
85+
except Exception as e:
86+
unreverted["heat_mode"] = {
87+
"expected": orig_heat_mode,
88+
"current": (await spa.get_status()).heat_mode,
89+
"error": str(e),
90+
}
91+
92+
# 3. Test set_temperature_format (toggle)
93+
new_format = (
94+
Spa.TemperatureFormat.FAHRENHEIT
95+
if orig_temp_format != Spa.TemperatureFormat.FAHRENHEIT
96+
else Spa.TemperatureFormat.CELSIUS
97+
)
98+
try:
99+
await spa.set_temperature_format(new_format)
100+
await _wait_for(
101+
lambda: spa.get_status(),
102+
lambda s: s.display_temperature_format == new_format.name,
103+
)
104+
except Exception as e:
105+
print(f"WARNING: Could not set temperature format to {new_format}: {e}")
106+
# Restore
107+
try:
108+
await spa.set_temperature_format(orig_temp_format)
109+
await _wait_for(
110+
lambda: spa.get_status(),
111+
lambda s: s.display_temperature_format == orig_temp_format.name,
112+
)
113+
except Exception as e:
114+
unreverted["temperature_format"] = {
115+
"expected": orig_temp_format,
116+
"current": (await spa.get_status()).display_temperature_format,
117+
"error": str(e),
118+
}
119+
120+
# 4. Test SpaPump.toggle (first available pump)
121+
pumps = await spa.get_pumps()
122+
if pumps:
123+
pump = pumps[0]
124+
orig_pump_state = pump.state
125+
try:
126+
await pump.toggle()
127+
await _wait_for(
128+
lambda: spa.get_status_full(),
129+
lambda s: any(
130+
p.id == pump.id and p.state != orig_pump_state for p in s.pumps
131+
),
132+
)
133+
except Exception as e:
134+
print(f"WARNING: Could not toggle pump {pump.id}: {e}")
135+
# Restore
136+
try:
137+
await pump.toggle()
138+
await _wait_for(
139+
lambda: spa.get_status_full(),
140+
lambda s: any(
141+
p.id == pump.id and p.state == orig_pump_state for p in s.pumps
142+
),
143+
)
144+
except Exception as e:
145+
unreverted["pump"] = {
146+
"id": pump.id,
147+
"expected": orig_pump_state,
148+
"error": str(e),
149+
}
150+
151+
# 5. Test SpaLight.set_mode (first available light)
152+
lights = await spa.get_lights()
153+
if lights:
154+
light = lights[0]
155+
orig_light_mode = light.mode
156+
orig_light_intensity = light.intensity
157+
try:
158+
# Set to ON if currently OFF, else OFF
159+
if orig_light_mode.name == "OFF":
160+
await light.set_mode(light.LightMode.RED, 50)
161+
await _wait_for(
162+
lambda: spa.get_status_full(),
163+
lambda s: any(
164+
light_obj.zone == light.zone
165+
and light_obj.mode == light.LightMode.RED
166+
for light_obj in s.lights
167+
),
168+
)
169+
# Restore
170+
await light.set_mode(light.LightMode.OFF, 0)
171+
await _wait_for(
172+
lambda: spa.get_status_full(),
173+
lambda s: any(
174+
light_obj.zone == light.zone
175+
and light_obj.mode == light.LightMode.OFF
176+
for light_obj in s.lights
177+
),
178+
)
179+
else:
180+
await light.set_mode(light.LightMode.OFF, 0)
181+
await _wait_for(
182+
lambda: spa.get_status_full(),
183+
lambda s: any(
184+
light_obj.zone == light.zone
185+
and light_obj.mode == light.LightMode.OFF
186+
for light_obj in s.lights
187+
),
188+
)
189+
# Restore
190+
await light.set_mode(orig_light_mode, orig_light_intensity)
191+
await _wait_for(
192+
lambda: spa.get_status_full(),
193+
lambda s: any(
194+
light_obj.zone == light.zone
195+
and light_obj.mode == orig_light_mode
196+
for light_obj in s.lights
197+
),
198+
)
199+
except Exception as e:
200+
unreverted["light"] = {
201+
"zone": light.zone,
202+
"expected": orig_light_mode,
203+
"intensity": orig_light_intensity,
204+
"error": str(e),
205+
}
206+
207+
# Final check and report
208+
print("\n--- Integration Test State Revert Report ---")
209+
if not unreverted:
210+
print("All state changes reverted successfully.")
211+
else:
212+
print("Some state could not be reverted. Please check and fix manually:")
213+
for key, info in unreverted.items():
214+
print(f" {key}: {info}")
215+
216+
217+
def c_to_f(c):
218+
return c * 9 / 5 + 32
219+
220+
221+
async def _wait_for(
222+
get_status, check_func, timeout=TIMEOUT, poll_interval=POLL_INTERVAL
223+
):
224+
import time
225+
226+
start = time.time()
227+
while True:
228+
state = await get_status()
229+
if check_func(state):
230+
return
231+
if time.time() - start > timeout:
232+
raise RuntimeError("State change not reflected within timeout period")
233+
await asyncio.sleep(poll_interval)

tests/test_pump.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ def pumps(mock_spa):
1313
**{
1414
"id": "pid1",
1515
"speed": "speed1",
16-
"state": "OFF"
17-
if pump_type == SpaPump.PumpType.CIRCULATION
18-
else SpaPump.PumpState.HIGH.name,
16+
"state": (
17+
"OFF"
18+
if pump_type == SpaPump.PumpType.CIRCULATION
19+
else SpaPump.PumpState.HIGH.name
20+
),
1921
"type": pump_type.name,
2022
},
2123
)

0 commit comments

Comments
 (0)