Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ venv
.coverage
.idea
node_modules
tests/LOGS/

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this specific to your environment? I don't recall ever seeing this generated.

52 changes: 49 additions & 3 deletions custom_components/bestway/aws_iot/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,14 @@ def normalize_aws_state(device_state: dict[str, Any]) -> dict[str, Any]:
# V02 wave_state actual values: 0=OFF, 40=MEDIUM, 100=HIGH
# Map to V01 Airjet format (0/50/100) for AIRJET_V01_BUBBLES_MAP compatibility
# Note: Hydrojet uses 40 for MEDIUM, so no mapping needed there
Comment on lines 127 to 128
_LOGGER.debug("🔵 normalize_aws_state: INPUT wave_state=%s", wave_state)
if wave_state == 40:
wave_normalized = 50 # Map V02 MEDIUM (40) → V01 Airjet MEDIUM (50)
else:
wave_normalized = wave_state # 0 and 100 are same in both
_LOGGER.debug(
"🔵 normalize_aws_state: OUTPUT wave_normalized=%s", wave_normalized
)

# Build normalized dict, only including fields with actual values
# This prevents None values from overwriting existing data during merges
Expand Down Expand Up @@ -848,6 +852,8 @@ async def airjet_v01_spa_set_bubbles(
Physical button cycles: OFF → HIGH → MEDIUM → OFF
Try sending absolute values first (simplest approach).
"""
_LOGGER.debug("🔵 airjet_v01_spa_set_bubbles: input level=%s", level)

# Map BubblesLevel enum to V02 wave_state values
value_map = {
BubblesLevel.OFF: 0,
Expand All @@ -856,9 +862,45 @@ async def airjet_v01_spa_set_bubbles(
}

target_value = value_map.get(level)
if target_value is not None:
await self.set_device_state(device_id, {"wave_state": target_value})
_LOGGER.debug("Set bubbles to %s (wave_state=%d)", level.name, target_value)
_LOGGER.debug("🔵 airjet_v01_spa_set_bubbles: target_value=%s", target_value)

if target_value is None:
return

# Some V02 Hydrojet devices ignore a direct MEDIUM (40) command when currently OFF.
# Physical button cycles: OFF -> HIGH -> MEDIUM -> OFF. To reliably reach MEDIUM
# from OFF, send a HIGH (100) toggle first, wait briefly, then send MEDIUM (40).
try:
cached = self._state_cache.get(device_id)
current_wave = None
if cached and isinstance(cached.attrs, dict):
current_wave = cached.attrs.get("wave")
except Exception:
current_wave = None

# If requesting MEDIUM but currently OFF, perform a toggle sequence
if target_value == 40 and (current_wave is None or int(current_wave) == 0):
_LOGGER.debug(
"🔵 airjet_v01_spa_set_bubbles: current_wave=%s, sending toggle sequence HIGH->MEDIUM",
current_wave,
)
# Send HIGH first
await self.set_device_state(device_id, {"wave_state": 100})
Comment on lines +870 to +888
# Give device a short moment to apply
try:
await asyncio.sleep(1)
except Exception:
pass
# Now send MEDIUM
await self.set_device_state(device_id, {"wave_state": 40})
_LOGGER.debug(
"🔵 Set bubbles to %s (wave_state=%d after toggle)", level.name, 40
)
return

# Default: send the requested absolute value
await self.set_device_state(device_id, {"wave_state": target_value})
_LOGGER.debug("🔵 Set bubbles to %s (wave_state=%d)", level.name, target_value)

async def hydrojet_spa_set_bubbles(
self, device_id: str, level: BubblesLevel
Expand All @@ -867,6 +909,10 @@ async def hydrojet_spa_set_bubbles(

V02 uses same toggle approach as Airjet V02.
"""
_LOGGER.debug(

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lot of log messages have been added in this PR. This appears to be to aid "debugging by print statements" in the absence of a proper debugger connection. Furthermore, they all contain emojis, which is inconsistent with the existing style, and strongly hints at an unreviewed AI generated change. Please can these be stripped back to an appropriate level.

"🔵 hydrojet_spa_set_bubbles: delegating to airjet_v01_spa_set_bubbles with level=%s",
level,
)
# V02 uses toggle approach (same as Airjet V02)
await self.airjet_v01_spa_set_bubbles(device_id, level)

Expand Down
3 changes: 2 additions & 1 deletion custom_components/bestway/bestway/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ def from_api_value(self, value: int) -> BubblesLevel:

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


@dataclass
Expand Down
26 changes: 24 additions & 2 deletions custom_components/bestway/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from logging import getLogger

from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.config_entries import ConfigEntry
Expand All @@ -22,6 +23,8 @@
from .const import DOMAIN, Icon
from .entity import BestwayEntity

_LOGGER = getLogger(__name__)

_BUBBLES_OPTIONS = {
BubblesLevel.OFF: "OFF",
BubblesLevel.MEDIUM: "MEDIUM",
Expand Down Expand Up @@ -121,19 +124,38 @@ def __init__(
def current_option(self) -> str | None:
"""Return the selected entity option."""
if device := self.coordinator.data.devices.get(self.device_id):
bubbles_level = self.entity_description.get_fn(device.attrs["wave"])
return _BUBBLES_OPTIONS.get(bubbles_level)
wave_value = device.attrs.get("wave")
_LOGGER.debug("🔵 current_option: device.attrs['wave']=%s", wave_value)

# Ensure we pass an int to the mapping function (mypy-friendly).
try:
wave_int: int = int(wave_value) if wave_value is not None else 0
except Exception:
wave_int = 0

bubbles_level = self.entity_description.get_fn(wave_int)
_LOGGER.debug("🔵 current_option: mapped to BubblesLevel=%s", bubbles_level)

option = _BUBBLES_OPTIONS.get(bubbles_level)
_LOGGER.debug("🔵 current_option: final option=%s", option)
return option
return None

async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
_LOGGER.debug("🔵 async_select_option: user selected option=%s", option)

bubbles_level = BubblesLevel.OFF
if option == _BUBBLES_OPTIONS[BubblesLevel.MEDIUM]:
bubbles_level = BubblesLevel.MEDIUM
elif option == _BUBBLES_OPTIONS[BubblesLevel.MAX]:
bubbles_level = BubblesLevel.MAX

_LOGGER.debug(
"🔵 async_select_option: mapped to BubblesLevel=%s", bubbles_level
)
await self.entity_description.set_fn(
self.coordinator.api, self.device_id, bubbles_level
)
_LOGGER.debug("🔵 async_select_option: API call complete, requesting refresh")
Comment on lines +128 to +160
await self.coordinator.async_request_refresh()
51 changes: 51 additions & 0 deletions tests/test_aws_iot_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,11 +230,24 @@ def test_normalize_state_partial_update_preserves_absent_fields():

def test_normalize_state_wave_mapping():
"""Test V02 wave_state value mapping to V01 format."""
from custom_components.bestway.bestway.model import (
HYDROJET_BUBBLES_MAP,
BubblesLevel,
)

# V02 MEDIUM (40) maps to V01 Airjet MEDIUM (50)
aws_state = {"wave_state": 40}
normalized = AwsIotApi.normalize_aws_state(aws_state)
assert normalized["wave"] == 50

# Hydrojet may report 41; treat it as MEDIUM as well.
aws_state = {"wave_state": 41}
normalized = AwsIotApi.normalize_aws_state(aws_state)
assert normalized["wave"] == 41
assert (
HYDROJET_BUBBLES_MAP.from_api_value(normalized["wave"]) == BubblesLevel.MEDIUM
)

# 0 and 100 are the same in both versions
aws_state = {"wave_state": 0}
normalized = AwsIotApi.normalize_aws_state(aws_state)
Expand Down Expand Up @@ -331,6 +344,44 @@ async def test_set_device_state_sends_command(aws_api, mock_session):
assert mock_session.post.called


@pytest.mark.asyncio
async def test_hydrojet_medium_from_off_uses_toggle_sequence(aws_api):
"""Test MEDIUM from OFF sends HIGH first, then MEDIUM."""
from custom_components.bestway.bestway.model import (
BestwayDevice,
BestwayDeviceStatus,
BubblesLevel,
)
from unittest.mock import AsyncMock, patch

aws_api.devices = {
"device1": BestwayDevice(
protocol_version=2,
device_id="device1",
product_name="AIRJET",
alias="Test Spa",
mcu_soft_version="unknown",
mcu_hard_version="unknown",
wifi_soft_version="unknown",
wifi_hard_version="unknown",
is_online=True,
backend="aws_iot",
product_id="T53NN8",
)
Comment on lines +357 to +370
}
aws_api._state_cache = {
"device1": BestwayDeviceStatus(timestamp=0, attrs={"wave": 0})
}
aws_api.set_device_state = AsyncMock(return_value=True)

with patch("custom_components.bestway.aws_iot.api.asyncio.sleep", new=AsyncMock()):
await aws_api.hydrojet_spa_set_bubbles("device1", BubblesLevel.MEDIUM)

assert aws_api.set_device_state.await_count == 2
assert aws_api.set_device_state.await_args_list[0].args[1] == {"wave_state": 100}
assert aws_api.set_device_state.await_args_list[1].args[1] == {"wave_state": 40}


@pytest.mark.asyncio
async def test_do_get_handles_401(aws_api, mock_session):
"""Test _do_get raises AwsIotAuthException on HTTP 401."""
Expand Down
Loading