Files
core/tests/components/roborock/test_switch.py
T

685 lines
21 KiB
Python

"""Test Roborock Switch platform."""
from collections.abc import Callable
from datetime import timedelta
from typing import Any
import pytest
import roborock
from roborock import RoborockCommand, RoborockException
from roborock.data import RoborockDockTypeCode, RoborockStateCode
from roborock.device_features import RoborockDockFeatures
from roborock.roborock_message import RoborockDataProtocol, RoborockZeoProtocol
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON
from homeassistant.const import ATTR_ASSUMED_STATE, STATE_ON, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from .conftest import FakeDevice
from tests.common import (
MockConfigEntry,
async_fire_time_changed,
mock_restore_cache,
snapshot_platform,
)
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to set platforms used in the test."""
return [Platform.SWITCH]
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_switches(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
setup_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test switches and check test values are correctly set."""
await snapshot_platform(hass, entity_registry, snapshot, setup_entry.entry_id)
@pytest.mark.parametrize(
("entity_id"),
[
("switch.roborock_s7_maxv_dock_child_lock"),
("switch.roborock_s7_maxv_dock_status_indicator_light"),
("switch.roborock_s7_maxv_do_not_disturb"),
],
)
async def test_update_success(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
entity_id: str,
) -> None:
"""Test turning switch entities on and off."""
# The entity fixture in conftest.py starts with the switch on and will
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "on"
# Turn off the switch and verify the entity state is updated properly with
# the latest information from the trait.
assert hass.states.get(entity_id) is not None
await hass.services.async_call(
"switch",
SERVICE_TURN_OFF,
service_data=None,
blocking=True,
target={"entity_id": entity_id},
)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "off"
# Turn back on and verify the entity state is updated properly with the
# latest information from the trait
assert hass.states.get(entity_id) is not None
await hass.services.async_call(
"switch",
SERVICE_TURN_ON,
service_data=None,
blocking=True,
target={"entity_id": entity_id},
)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "on"
DOCK_SWITCH_ENTITY_IDS = {
"switch.roborock_s7_maxv_dock_dust_emptying",
"switch.roborock_s7_maxv_dock_mop_washing",
"switch.roborock_s7_maxv_dock_mop_drying",
}
@pytest.mark.parametrize(
("entity_id", "service", "expected_command", "expected_params"),
[
(
"switch.roborock_s7_maxv_dock_dust_emptying",
SERVICE_TURN_ON,
RoborockCommand.APP_START_COLLECT_DUST,
None,
),
(
"switch.roborock_s7_maxv_dock_dust_emptying",
SERVICE_TURN_OFF,
RoborockCommand.APP_STOP_COLLECT_DUST,
None,
),
(
"switch.roborock_s7_maxv_dock_mop_washing",
SERVICE_TURN_ON,
RoborockCommand.APP_START_WASH,
None,
),
(
"switch.roborock_s7_maxv_dock_mop_washing",
SERVICE_TURN_OFF,
RoborockCommand.APP_STOP_WASH,
None,
),
(
"switch.roborock_s7_maxv_dock_mop_drying",
SERVICE_TURN_ON,
RoborockCommand.APP_SET_DRYER_STATUS,
{"status": 1},
),
(
"switch.roborock_s7_maxv_dock_mop_drying",
SERVICE_TURN_OFF,
RoborockCommand.APP_SET_DRYER_STATUS,
{"status": 0},
),
],
)
async def test_dock_switch_commands(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
fake_vacuum: FakeDevice,
entity_id: str,
service: str,
expected_command: RoborockCommand,
expected_params: dict[str, int] | None,
) -> None:
"""Test the dock action switches send the right start and stop commands."""
assert hass.states.get(entity_id) is not None
await hass.services.async_call(
"switch",
service,
blocking=True,
target={"entity_id": entity_id},
)
fake_vacuum.v1_properties.command.send.assert_called_once_with(
expected_command, params=expected_params
)
@pytest.mark.parametrize(
("entity_id", "status_field", "value"),
[
pytest.param(
"switch.roborock_s7_maxv_dock_mop_washing",
"state",
RoborockStateCode.washing_the_mop,
id="wash-from-pushed-state",
),
pytest.param(
"switch.roborock_s7_maxv_dock_mop_washing",
"state",
RoborockStateCode.washing_the_mop_2,
id="wash-from-alternate-pushed-state",
),
pytest.param(
"switch.roborock_s7_maxv_dock_mop_drying",
"dry_status",
1,
id="drying-from-pushed-status",
),
],
)
async def test_dock_switch_reflects_status(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
fake_vacuum: FakeDevice,
entity_id: str,
status_field: str,
value: int | RoborockStateCode,
) -> None:
"""Test the dock action switches follow the status reported by the device."""
assert hass.states.get(entity_id).state == "off"
# Stop the mock trait from restoring the status template on every refresh.
fake_vacuum.v1_properties.status.refresh.side_effect = None
setattr(fake_vacuum.v1_properties.status, status_field, value)
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=1))
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == "on"
async def test_dock_switch_follows_pushed_dps(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
fake_vacuum: FakeDevice,
) -> None:
"""Test a dock switch follows the state pushed over MQTT, without polling.
Emptying only lasts a few seconds, so the switch relies on the device
pushing the state rather than on the next refresh.
"""
entity_id = "switch.roborock_s7_maxv_dock_dust_emptying"
assert hass.states.get(entity_id).state == "off"
fake_vacuum.v1_properties.status.update_from_dps(
{RoborockDataProtocol.STATE: RoborockStateCode.emptying_the_bin}
)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == "on"
fake_vacuum.v1_properties.status.update_from_dps(
{RoborockDataProtocol.STATE: RoborockStateCode.charging}
)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == "off"
async def test_mop_washing_ignores_going_to_wash_state(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
fake_vacuum: FakeDevice,
) -> None:
"""Test travelling to the dock to wash does not turn the wash switch on."""
fake_vacuum.v1_properties.status.refresh.side_effect = None
fake_vacuum.v1_properties.status.state = RoborockStateCode.going_to_wash_the_mop
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=1))
await hass.async_block_till_done()
assert hass.states.get("switch.roborock_s7_maxv_dock_mop_washing").state == "off"
@pytest.mark.parametrize(
("entity_id", "service", "expected_command"),
[
(
"switch.roborock_s7_maxv_dock_mop_washing",
SERVICE_TURN_ON,
"APP_START_WASH",
),
(
"switch.roborock_s7_maxv_dock_mop_washing",
SERVICE_TURN_OFF,
"APP_STOP_WASH",
),
(
"switch.roborock_s7_maxv_dock_mop_drying",
SERVICE_TURN_ON,
"APP_SET_DRYER_STATUS",
),
(
"switch.roborock_s7_maxv_dock_mop_drying",
SERVICE_TURN_OFF,
"APP_SET_DRYER_STATUS",
),
],
)
async def test_dock_switch_command_failure(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
fake_vacuum: FakeDevice,
entity_id: str,
service: str,
expected_command: str,
) -> None:
"""Test a failing dock command is raised to the user."""
fake_vacuum.v1_properties.command.send.side_effect = RoborockException
with pytest.raises(
HomeAssistantError, match=f"Error while calling {expected_command}"
):
await hass.services.async_call(
"switch",
service,
blocking=True,
target={"entity_id": entity_id},
)
@pytest.fixture
def dock_type(request: pytest.FixtureRequest, fake_vacuum: FakeDevice) -> None:
"""Report the parametrized dock type for the fake vacuum."""
fake_vacuum.v1_properties.device_features.dock_features = (
RoborockDockFeatures.from_dock_type(request.param)
)
@pytest.mark.parametrize(
("dock_type", "expected_entity_ids"),
[
pytest.param(
RoborockDockTypeCode.o1_dock,
{"switch.roborock_s7_maxv_dock_dust_emptying"},
id="collect-only",
),
pytest.param(
RoborockDockTypeCode.o2_dock,
{"switch.roborock_s7_maxv_dock_mop_washing"},
id="wash-only-no-dry",
),
pytest.param(
RoborockDockTypeCode.o3_dock,
{
"switch.roborock_s7_maxv_dock_dust_emptying",
"switch.roborock_s7_maxv_dock_mop_washing",
},
id="collect-and-wash-no-dry",
),
pytest.param(
RoborockDockTypeCode.shell_e_dock,
DOCK_SWITCH_ENTITY_IDS,
id="collect-wash-and-dry",
),
pytest.param(RoborockDockTypeCode.o0_dock, set(), id="no-dock"),
],
indirect=["dock_type"],
)
@pytest.mark.usefixtures("dock_type")
async def test_dock_switches_match_dock_capabilities(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
expected_entity_ids: set[str],
) -> None:
"""Test only the dock action switches the dock supports are created."""
created = {
entity_id
for entity_id in DOCK_SWITCH_ENTITY_IDS
if hass.states.get(entity_id) is not None
}
assert created == expected_entity_ids
@pytest.mark.parametrize(
("entity_id", "service", "expected_call_fn"),
[
(
"switch.roborock_s7_maxv_dock_status_indicator_light",
SERVICE_TURN_ON,
lambda trait: trait.flow_led_status.enable,
),
(
"switch.roborock_s7_maxv_dock_status_indicator_light",
SERVICE_TURN_OFF,
lambda trait: trait.flow_led_status.disable,
),
],
)
@pytest.mark.parametrize(
"send_message_exception", [roborock.exceptions.RoborockTimeout]
)
async def test_update_failed(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
entity_id: str,
service: str,
fake_vacuum: FakeDevice,
expected_call_fn: Callable[[Any], Any],
) -> None:
"""Test a failure while updating a switch."""
expected_call = expected_call_fn(fake_vacuum.v1_properties)
expected_call.side_effect = roborock.exceptions.RoborockTimeout
# Ensure that the entity exist, as these test can pass even if there is no entity.
assert hass.states.get(entity_id) is not None
with (
pytest.raises(HomeAssistantError, match="Failed to update Roborock options"),
):
await hass.services.async_call(
"switch",
service,
service_data=None,
blocking=True,
target={"entity_id": entity_id},
)
assert len(expected_call.mock_calls) == 1
@pytest.mark.parametrize(
("entity_id"),
[
("switch.zeo_one_sound_setting"),
],
)
async def test_a01_switch_success(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
entity_id: str,
fake_devices: list[FakeDevice],
) -> None:
"""Test turning A01 switch entities on and off."""
# Get the washing machine (A01) device
washing_machine = next(
device
for device in fake_devices
if hasattr(device, "zeo") and device.zeo is not None
)
# Verify entity exists
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "off"
# Turn on the switch
await hass.services.async_call(
"switch",
SERVICE_TURN_ON,
service_data=None,
blocking=True,
target={"entity_id": entity_id},
)
# Verify set_value was called with the correct value (1 for on)
washing_machine.zeo.set_value.assert_called_with(RoborockZeoProtocol.SOUND_SET, 1)
# Turn off the switch
await hass.services.async_call(
"switch",
SERVICE_TURN_OFF,
service_data=None,
blocking=True,
target={"entity_id": entity_id},
)
# Verify set_value was called with the correct value (0 for off)
washing_machine.zeo.set_value.assert_called_with(RoborockZeoProtocol.SOUND_SET, 0)
@pytest.mark.parametrize(
("entity_id", "service"),
[
("switch.zeo_one_sound_setting", SERVICE_TURN_ON),
("switch.zeo_one_sound_setting", SERVICE_TURN_OFF),
],
)
async def test_a01_switch_failure(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
entity_id: str,
service: str,
fake_devices: list[FakeDevice],
) -> None:
"""Test a failure while updating an A01 switch."""
# Get the washing machine (A01) device
washing_machine = next(
device
for device in fake_devices
if hasattr(device, "zeo") and device.zeo is not None
)
washing_machine.zeo.set_value.side_effect = roborock.exceptions.RoborockTimeout
# Ensure that the entity exists
assert hass.states.get(entity_id) is not None
with pytest.raises(HomeAssistantError, match="Failed to update Roborock options"):
await hass.services.async_call(
"switch",
service,
service_data=None,
blocking=True,
target={"entity_id": entity_id},
)
assert len(washing_machine.zeo.set_value.mock_calls) >= 1
async def test_a01_switch_unknown_state(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
fake_devices: list[FakeDevice],
) -> None:
"""Test A01 switch returns unknown when API omits the protocol key."""
entity_id = "switch.zeo_one_sound_setting"
# Verify entity exists with a known state initially
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "off"
# Simulate the API returning data without the SOUND_SET key
washing_machine = next(
device
for device in fake_devices
if hasattr(device, "zeo") and device.zeo is not None
)
incomplete_data = {
k: v
for k, v in washing_machine.zeo.query_values.return_value.items()
if k != RoborockZeoProtocol.SOUND_SET
}
washing_machine.zeo.query_values.return_value = incomplete_data
# Trigger a coordinator refresh
async_fire_time_changed(
hass,
dt_util.utcnow() + timedelta(seconds=61),
)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "unknown"
@pytest.mark.parametrize(
("entity_id", "trait_name"),
[
("switch.roborock_q10_s5_do_not_disturb", "do_not_disturb"),
("switch.roborock_q10_s5_child_lock", "child_lock"),
("switch.roborock_q10_s5_dust_collection", "dust_collection"),
],
)
async def test_q10_switch_success(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
fake_q10_vacuum: FakeDevice,
entity_id: str,
trait_name: str,
) -> None:
"""Test turning Q10 switch entities on and off."""
assert hass.states.get(entity_id) is not None
await hass.services.async_call(
"switch",
SERVICE_TURN_OFF,
service_data=None,
blocking=True,
target={"entity_id": entity_id},
)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "off"
await hass.services.async_call(
"switch",
SERVICE_TURN_ON,
service_data=None,
blocking=True,
target={"entity_id": entity_id},
)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "on"
assert fake_q10_vacuum.b01_q10_properties is not None
trait = getattr(fake_q10_vacuum.b01_q10_properties, trait_name)
trait.enable.assert_awaited_once()
trait.disable.assert_awaited_once()
@pytest.mark.parametrize(
("entity_id", "trait_name"),
[
("switch.roborock_q10_s5_do_not_disturb", "do_not_disturb"),
("switch.roborock_q10_s5_child_lock", "child_lock"),
("switch.roborock_q10_s5_dust_collection", "dust_collection"),
],
)
async def test_q10_switch_failure(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
fake_q10_vacuum: FakeDevice,
entity_id: str,
trait_name: str,
) -> None:
"""Test a failure while updating a Q10 switch."""
assert fake_q10_vacuum.b01_q10_properties is not None
trait = getattr(fake_q10_vacuum.b01_q10_properties, trait_name)
trait.enable.side_effect = roborock.exceptions.RoborockTimeout
assert hass.states.get(entity_id) is not None
with pytest.raises(HomeAssistantError, match="Failed to update Roborock options"):
await hass.services.async_call(
"switch",
SERVICE_TURN_ON,
service_data=None,
blocking=True,
target={"entity_id": entity_id},
)
async def test_q10_button_light_switch(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
fake_q10_vacuum: FakeDevice,
) -> None:
"""Test the Q10 write-only indicator light switch assumes its state."""
entity_id = "switch.roborock_q10_s5_indicator_light"
# The device never reports the light state, so it starts unknown
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNKNOWN
assert state.attributes.get(ATTR_ASSUMED_STATE) is True
await hass.services.async_call(
"switch",
SERVICE_TURN_ON,
service_data=None,
blocking=True,
target={"entity_id": entity_id},
)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "on"
await hass.services.async_call(
"switch",
SERVICE_TURN_OFF,
service_data=None,
blocking=True,
target={"entity_id": entity_id},
)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "off"
assert fake_q10_vacuum.b01_q10_properties is not None
fake_q10_vacuum.b01_q10_properties.button_light.enable.assert_awaited_once()
fake_q10_vacuum.b01_q10_properties.button_light.disable.assert_awaited_once()
async def test_q10_button_light_switch_restore_state(
hass: HomeAssistant,
mock_roborock_entry: MockConfigEntry,
) -> None:
"""Test the Q10 indicator light restores its assumed state after a restart."""
entity_id = "switch.roborock_q10_s5_indicator_light"
mock_restore_cache(hass, (State(entity_id, STATE_ON),))
await hass.config_entries.async_setup(mock_roborock_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_ON
async def test_q10_button_light_switch_failure(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
fake_q10_vacuum: FakeDevice,
) -> None:
"""Test the Q10 indicator light keeps its state on a failed command."""
entity_id = "switch.roborock_q10_s5_indicator_light"
assert fake_q10_vacuum.b01_q10_properties is not None
fake_q10_vacuum.b01_q10_properties.button_light.enable.side_effect = (
roborock.exceptions.RoborockTimeout
)
assert hass.states.get(entity_id) is not None
with pytest.raises(HomeAssistantError, match="Failed to update Roborock options"):
await hass.services.async_call(
"switch",
SERVICE_TURN_ON,
service_data=None,
blocking=True,
target={"entity_id": entity_id},
)
# The failed command must not flip the assumed state
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNKNOWN