Skip to content

Commit 0baf12d

Browse files
committed
Fix Hydrojet V02 bubbles handling
1 parent dd9cda1 commit 0baf12d

5 files changed

Lines changed: 127 additions & 6 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ venv
77
.coverage
88
.idea
99
node_modules
10+
tests/LOGS/

custom_components/bestway/aws_iot/api.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,14 @@ def normalize_aws_state(device_state: dict[str, Any]) -> dict[str, Any]:
126126
# V02 wave_state actual values: 0=OFF, 40=MEDIUM, 100=HIGH
127127
# Map to V01 Airjet format (0/50/100) for AIRJET_V01_BUBBLES_MAP compatibility
128128
# Note: Hydrojet uses 40 for MEDIUM, so no mapping needed there
129+
_LOGGER.debug("🔵 normalize_aws_state: INPUT wave_state=%s", wave_state)
129130
if wave_state == 40:
130131
wave_normalized = 50 # Map V02 MEDIUM (40) → V01 Airjet MEDIUM (50)
131132
else:
132133
wave_normalized = wave_state # 0 and 100 are same in both
134+
_LOGGER.debug(
135+
"🔵 normalize_aws_state: OUTPUT wave_normalized=%s", wave_normalized
136+
)
133137

134138
# Build normalized dict, only including fields with actual values
135139
# This prevents None values from overwriting existing data during merges
@@ -848,6 +852,8 @@ async def airjet_v01_spa_set_bubbles(
848852
Physical button cycles: OFF → HIGH → MEDIUM → OFF
849853
Try sending absolute values first (simplest approach).
850854
"""
855+
_LOGGER.debug("🔵 airjet_v01_spa_set_bubbles: input level=%s", level)
856+
851857
# Map BubblesLevel enum to V02 wave_state values
852858
value_map = {
853859
BubblesLevel.OFF: 0,
@@ -856,9 +862,45 @@ async def airjet_v01_spa_set_bubbles(
856862
}
857863

858864
target_value = value_map.get(level)
859-
if target_value is not None:
860-
await self.set_device_state(device_id, {"wave_state": target_value})
861-
_LOGGER.debug("Set bubbles to %s (wave_state=%d)", level.name, target_value)
865+
_LOGGER.debug("🔵 airjet_v01_spa_set_bubbles: target_value=%s", target_value)
866+
867+
if target_value is None:
868+
return
869+
870+
# Some V02 Hydrojet devices ignore a direct MEDIUM (40) command when currently OFF.
871+
# Physical button cycles: OFF -> HIGH -> MEDIUM -> OFF. To reliably reach MEDIUM
872+
# from OFF, send a HIGH (100) toggle first, wait briefly, then send MEDIUM (40).
873+
try:
874+
cached = self._state_cache.get(device_id)
875+
current_wave = None
876+
if cached and isinstance(cached.attrs, dict):
877+
current_wave = cached.attrs.get("wave")
878+
except Exception:
879+
current_wave = None
880+
881+
# If requesting MEDIUM but currently OFF, perform a toggle sequence
882+
if target_value == 40 and (current_wave is None or int(current_wave) == 0):
883+
_LOGGER.debug(
884+
"🔵 airjet_v01_spa_set_bubbles: current_wave=%s, sending toggle sequence HIGH->MEDIUM",
885+
current_wave,
886+
)
887+
# Send HIGH first
888+
await self.set_device_state(device_id, {"wave_state": 100})
889+
# Give device a short moment to apply
890+
try:
891+
await asyncio.sleep(1)
892+
except Exception:
893+
pass
894+
# Now send MEDIUM
895+
await self.set_device_state(device_id, {"wave_state": 40})
896+
_LOGGER.debug(
897+
"🔵 Set bubbles to %s (wave_state=%d after toggle)", level.name, 40
898+
)
899+
return
900+
901+
# Default: send the requested absolute value
902+
await self.set_device_state(device_id, {"wave_state": target_value})
903+
_LOGGER.debug("🔵 Set bubbles to %s (wave_state=%d)", level.name, target_value)
862904

863905
async def hydrojet_spa_set_bubbles(
864906
self, device_id: str, level: BubblesLevel
@@ -867,6 +909,10 @@ async def hydrojet_spa_set_bubbles(
867909
868910
V02 uses same toggle approach as Airjet V02.
869911
"""
912+
_LOGGER.debug(
913+
"🔵 hydrojet_spa_set_bubbles: delegating to airjet_v01_spa_set_bubbles with level=%s",
914+
level,
915+
)
870916
# V02 uses toggle approach (same as Airjet V02)
871917
await self.airjet_v01_spa_set_bubbles(device_id, level)
872918

custom_components/bestway/bestway/model.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,8 @@ def from_api_value(self, value: int) -> BubblesLevel:
152152

153153
BV = BubblesValues
154154
AIRJET_V01_BUBBLES_MAP = BubblesMapping(BV(0), BV(50, [40, 41, 50, 51]), BV(100))
155-
HYDROJET_BUBBLES_MAP = BubblesMapping(BV(0), BV(40), BV(100))
155+
# Hydrojet devices sometimes report MEDIUM as 40, 41 or (after normalization) 50 — accept all
156+
HYDROJET_BUBBLES_MAP = BubblesMapping(BV(0), BV(40, [40, 41, 50]), BV(100))
156157

157158

158159
@dataclass

custom_components/bestway/select.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from collections.abc import Awaitable, Callable
66
from dataclasses import dataclass
7+
from logging import getLogger
78

89
from homeassistant.components.select import SelectEntity, SelectEntityDescription
910
from homeassistant.config_entries import ConfigEntry
@@ -22,6 +23,8 @@
2223
from .const import DOMAIN, Icon
2324
from .entity import BestwayEntity
2425

26+
_LOGGER = getLogger(__name__)
27+
2528
_BUBBLES_OPTIONS = {
2629
BubblesLevel.OFF: "OFF",
2730
BubblesLevel.MEDIUM: "MEDIUM",
@@ -121,19 +124,38 @@ def __init__(
121124
def current_option(self) -> str | None:
122125
"""Return the selected entity option."""
123126
if device := self.coordinator.data.devices.get(self.device_id):
124-
bubbles_level = self.entity_description.get_fn(device.attrs["wave"])
125-
return _BUBBLES_OPTIONS.get(bubbles_level)
127+
wave_value = device.attrs.get("wave")
128+
_LOGGER.debug("🔵 current_option: device.attrs['wave']=%s", wave_value)
129+
130+
# Ensure we pass an int to the mapping function (mypy-friendly).
131+
try:
132+
wave_int: int = int(wave_value) if wave_value is not None else 0
133+
except Exception:
134+
wave_int = 0
135+
136+
bubbles_level = self.entity_description.get_fn(wave_int)
137+
_LOGGER.debug("🔵 current_option: mapped to BubblesLevel=%s", bubbles_level)
138+
139+
option = _BUBBLES_OPTIONS.get(bubbles_level)
140+
_LOGGER.debug("🔵 current_option: final option=%s", option)
141+
return option
126142
return None
127143

128144
async def async_select_option(self, option: str) -> None:
129145
"""Change the selected option."""
146+
_LOGGER.debug("🔵 async_select_option: user selected option=%s", option)
147+
130148
bubbles_level = BubblesLevel.OFF
131149
if option == _BUBBLES_OPTIONS[BubblesLevel.MEDIUM]:
132150
bubbles_level = BubblesLevel.MEDIUM
133151
elif option == _BUBBLES_OPTIONS[BubblesLevel.MAX]:
134152
bubbles_level = BubblesLevel.MAX
135153

154+
_LOGGER.debug(
155+
"🔵 async_select_option: mapped to BubblesLevel=%s", bubbles_level
156+
)
136157
await self.entity_description.set_fn(
137158
self.coordinator.api, self.device_id, bubbles_level
138159
)
160+
_LOGGER.debug("🔵 async_select_option: API call complete, requesting refresh")
139161
await self.coordinator.async_request_refresh()

tests/test_aws_iot_api.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,11 +230,24 @@ def test_normalize_state_partial_update_preserves_absent_fields():
230230

231231
def test_normalize_state_wave_mapping():
232232
"""Test V02 wave_state value mapping to V01 format."""
233+
from custom_components.bestway.bestway.model import (
234+
HYDROJET_BUBBLES_MAP,
235+
BubblesLevel,
236+
)
237+
233238
# V02 MEDIUM (40) maps to V01 Airjet MEDIUM (50)
234239
aws_state = {"wave_state": 40}
235240
normalized = AwsIotApi.normalize_aws_state(aws_state)
236241
assert normalized["wave"] == 50
237242

243+
# Hydrojet may report 41; treat it as MEDIUM as well.
244+
aws_state = {"wave_state": 41}
245+
normalized = AwsIotApi.normalize_aws_state(aws_state)
246+
assert normalized["wave"] == 41
247+
assert (
248+
HYDROJET_BUBBLES_MAP.from_api_value(normalized["wave"]) == BubblesLevel.MEDIUM
249+
)
250+
238251
# 0 and 100 are the same in both versions
239252
aws_state = {"wave_state": 0}
240253
normalized = AwsIotApi.normalize_aws_state(aws_state)
@@ -331,6 +344,44 @@ async def test_set_device_state_sends_command(aws_api, mock_session):
331344
assert mock_session.post.called
332345

333346

347+
@pytest.mark.asyncio
348+
async def test_hydrojet_medium_from_off_uses_toggle_sequence(aws_api):
349+
"""Test MEDIUM from OFF sends HIGH first, then MEDIUM."""
350+
from custom_components.bestway.bestway.model import (
351+
BestwayDevice,
352+
BestwayDeviceStatus,
353+
BubblesLevel,
354+
)
355+
from unittest.mock import AsyncMock, patch
356+
357+
aws_api.devices = {
358+
"device1": BestwayDevice(
359+
protocol_version=2,
360+
device_id="device1",
361+
product_name="AIRJET",
362+
alias="Test Spa",
363+
mcu_soft_version="unknown",
364+
mcu_hard_version="unknown",
365+
wifi_soft_version="unknown",
366+
wifi_hard_version="unknown",
367+
is_online=True,
368+
backend="aws_iot",
369+
product_id="T53NN8",
370+
)
371+
}
372+
aws_api._state_cache = {
373+
"device1": BestwayDeviceStatus(timestamp=0, attrs={"wave": 0})
374+
}
375+
aws_api.set_device_state = AsyncMock(return_value=True)
376+
377+
with patch("custom_components.bestway.aws_iot.api.asyncio.sleep", new=AsyncMock()):
378+
await aws_api.hydrojet_spa_set_bubbles("device1", BubblesLevel.MEDIUM)
379+
380+
assert aws_api.set_device_state.await_count == 2
381+
assert aws_api.set_device_state.await_args_list[0].args[1] == {"wave_state": 100}
382+
assert aws_api.set_device_state.await_args_list[1].args[1] == {"wave_state": 40}
383+
384+
334385
@pytest.mark.asyncio
335386
async def test_do_get_handles_401(aws_api, mock_session):
336387
"""Test _do_get raises AwsIotAuthException on HTTP 401."""

0 commit comments

Comments
 (0)