Add cover speeds for SwitchBot curtains (#182416)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Joost Lekkerkerker
2026-09-21 19:14:34 +02:00
committed by GitHub
co-authored by Claude
parent 62c9711cdd
commit 71e382e3a8
4 changed files with 153 additions and 7 deletions
@@ -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,
+21 -6
View File
@@ -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()
@@ -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"
}
}
}
},
+115 -1
View File
@@ -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: