diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index e1cab0c89c31..e6e299d92963 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -261,6 +261,17 @@ ROLLER_SHADE_SPEED_TO_MODE = { ROLLER_SHADE_SPEED_QUIET: 1, } +# Curtain movement speeds, exposed as cover speeds. +# Only the Curtain 3 (identified by its advertised model friendly name) +# honours the speed byte; other curtain models always move at normal speed. +CURTAIN_3_MODEL_FRIENDLY_NAME = "Curtain 3" +CURTAIN_SPEED_NORMAL = "normal" +CURTAIN_SPEED_SILENT = "silent" +CURTAIN_SPEED_TO_VALUE = { + CURTAIN_SPEED_NORMAL: 255, + CURTAIN_SPEED_SILENT: 1, +} + AIRPURIFIER_BASIC_MODELS = { SwitchbotModel.AIR_PURIFIER_JP, SwitchbotModel.AIR_PURIFIER_US, diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index 411e4f05dc26..021fc9231853 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -20,6 +20,8 @@ from homeassistant.helpers.restore_state import RestoreEntity from .const import ( CONF_CURTAIN_SPEED, + CURTAIN_3_MODEL_FRIENDLY_NAME, + CURTAIN_SPEED_TO_VALUE, DEFAULT_CURTAIN_SPEED, ROLLER_SHADE_SPEED_PERFORMANCE, ROLLER_SHADE_SPEED_TO_MODE, @@ -67,10 +69,22 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): """Initialize the Switchbot.""" super().__init__(coordinator) self._attr_is_closed = None + # Only the Curtain 3 honours the speed byte; expose cover speeds + # (e.g. silent mode) for that model only. + if self._device.data.get("modelFriendlyName") == CURTAIN_3_MODEL_FRIENDLY_NAME: + self._attr_supported_features |= CoverEntityFeature.SPEED + self._attr_supported_speeds = list(CURTAIN_SPEED_TO_VALUE) @callback - def _get_curtain_speed(self) -> int: - """Return the configured curtain speed.""" + def _get_curtain_speed(self, kwargs: dict[str, Any]) -> int: + """Return the movement speed for the requested cover speed. + + A speed passed to the action takes precedence; otherwise the speed + configured in the options flow is used. + """ + if (speed := kwargs.get(ATTR_SPEED)) is not None: + # The cover entity validated the speed against _attr_supported_speeds. + return CURTAIN_SPEED_TO_VALUE[speed] return int( self.coordinator.config_entry.options.get( CONF_CURTAIN_SPEED, DEFAULT_CURTAIN_SPEED @@ -101,7 +115,7 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): """Open the curtain.""" _LOGGER.debug("Switchbot to open curtain %s", self._address) - speed = self._get_curtain_speed() + speed = self._get_curtain_speed(kwargs) self._last_run_success = bool(await self._device.open(speed)) self._attr_is_opening = self._device.is_opening() self._attr_is_closing = self._device.is_closing() @@ -113,7 +127,7 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): """Close the curtain.""" _LOGGER.debug("Switchbot to close the curtain %s", self._address) - speed = self._get_curtain_speed() + speed = self._get_curtain_speed(kwargs) self._last_run_success = bool(await self._device.close(speed)) self._attr_is_opening = self._device.is_opening() self._attr_is_closing = self._device.is_closing() @@ -134,10 +148,11 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): @override async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover shutter to a specific position.""" - position = kwargs.get(ATTR_POSITION) + position = kwargs[ATTR_POSITION] _LOGGER.debug("Switchbot to move at %d %s", position, self._address) - self._last_run_success = bool(await self._device.set_position(position)) + speed = self._get_curtain_speed(kwargs) + self._last_run_success = bool(await self._device.set_position(position, speed)) self._attr_is_opening = self._device.is_opening() self._attr_is_closing = self._device.is_closing() self.async_write_ha_state() diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 87a6c93ebb76..9034516c0409 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -157,6 +157,12 @@ "false": "[%key:component::binary_sensor::entity_component::problem::state::on%]", "true": "[%key:component::binary_sensor::entity_component::problem::state::off%]" } + }, + "speed": { + "state": { + "normal": "Normal", + "silent": "Silent" + } } } }, diff --git a/tests/components/switchbot/test_cover.py b/tests/components/switchbot/test_cover.py index 6025464a1182..76131bf38e8a 100644 --- a/tests/components/switchbot/test_cover.py +++ b/tests/components/switchbot/test_cover.py @@ -15,17 +15,21 @@ from homeassistant.components.cover import ( ATTR_TILT_POSITION, DOMAIN as COVER_DOMAIN, CoverEntityCapabilityAttribute, + CoverEntityFeature, CoverState, ) from homeassistant.components.switchbot.const import ( CONF_CURTAIN_SPEED, CONF_RETRY_COUNT, + CURTAIN_SPEED_NORMAL, + CURTAIN_SPEED_SILENT, DEFAULT_RETRY_COUNT, ROLLER_SHADE_SPEED_PERFORMANCE, ROLLER_SHADE_SPEED_QUIET, ) from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, @@ -176,7 +180,7 @@ async def test_curtain3_controlling( ) await hass.async_block_till_done() - mock_set_position.assert_awaited_once() + mock_set_position.assert_awaited_once_with(50, 255) # Default speed state = hass.states.get(entity_id) assert state.state == CoverState.OPEN assert state.attributes[ATTR_CURRENT_POSITION] == 60 @@ -231,6 +235,116 @@ async def test_curtain3_custom_speed_controlling( mock_close.assert_awaited_once_with(50) +@pytest.mark.parametrize( + ("speed", "expected_value"), + [ + pytest.param(CURTAIN_SPEED_NORMAL, 255, id="normal"), + pytest.param(CURTAIN_SPEED_SILENT, 1, id="silent"), + ], +) +async def test_curtain3_speed( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + speed: str, + expected_value: int, +) -> None: + """Test the curtain forwards the requested cover speed to the device.""" + inject_bluetooth_service_info(hass, WOCURTAIN3_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="curtain") + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.open", + new=AsyncMock(return_value=True), + ) as mock_open, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.close", + new=AsyncMock(return_value=True), + ) as mock_close, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.set_position", + new=AsyncMock(return_value=True), + ) as mock_set_position, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "cover.test_name" + + state = hass.states.get(entity_id) + assert state.attributes[CoverEntityCapabilityAttribute.SUPPORTED_SPEEDS] == [ + CURTAIN_SPEED_NORMAL, + CURTAIN_SPEED_SILENT, + ] + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id, ATTR_SPEED: speed}, + blocking=True, + ) + mock_open.assert_awaited_once_with(expected_value) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id, ATTR_SPEED: speed}, + blocking=True, + ) + mock_close.assert_awaited_once_with(expected_value) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50, ATTR_SPEED: speed}, + blocking=True, + ) + mock_set_position.assert_awaited_once_with(50, expected_value) + + +async def test_curtain_speed_not_supported( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test a non-Curtain-3 curtain does not expose the speed feature.""" + # A Curtain (not Curtain 3) advertises the "c" device type in its service data. + inject_bluetooth_service_info( + hass, + make_advertisement( + "AA:BB:CC:DD:EE:FF", + b"\xcf;Zwu\x0c\x19\x0b\x00\x11D\x006", + b"c\xc06\x00\x11D", + ), + ) + + entry = mock_entry_factory(sensor_type="curtain") + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotCurtain.open", + new=AsyncMock(return_value=True), + ) as mock_open: + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "cover.test_name" + + state = hass.states.get(entity_id) + assert not state.attributes[ATTR_SUPPORTED_FEATURES] & CoverEntityFeature.SPEED + assert CoverEntityCapabilityAttribute.SUPPORTED_SPEEDS not in state.attributes + + # A speed passed to a model that does not support it is ignored and the + # curtain moves at the default speed. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id, ATTR_SPEED: CURTAIN_SPEED_SILENT}, + blocking=True, + ) + mock_open.assert_awaited_once_with(255) + + async def test_blindtilt_setup( hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] ) -> None: