diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index b29079affcf0309d858e9264627aaed5e8185cb9..4747d044bfaff149ca34f85851bafac57ce16e46 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -86,6 +86,7 @@ BLOCK_SLEEPING_PLATFORMS: Final = [ RPC_PLATFORMS: Final = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.COVER, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index f9bd1b7ce85960ed2e5ad522b5d952cc989fa4d5..4885a2a0d2eeae14ba485eb0dc005ee022f22a9b 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -18,15 +18,28 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BlockDeviceWrapper -from .const import BLOCK, DATA_CONFIG_ENTRY, DOMAIN -from .entity import ShellyBlockEntity +from . import BlockDeviceWrapper, RpcDeviceWrapper +from .const import BLOCK, DATA_CONFIG_ENTRY, DOMAIN, RPC +from .entity import ShellyBlockEntity, ShellyRpcEntity +from .utils import get_device_entry_gen, get_rpc_key_ids async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switches for device.""" + if get_device_entry_gen(config_entry) == 2: + return await async_setup_rpc_entry(hass, config_entry, async_add_entities) + + return await async_setup_block_entry(hass, config_entry, async_add_entities) + + +async def async_setup_block_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up cover for device.""" wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][BLOCK] @@ -35,16 +48,32 @@ async def async_setup_entry( if not blocks: return - async_add_entities(ShellyCover(wrapper, block) for block in blocks) + async_add_entities(BlockShellyCover(wrapper, block) for block in blocks) -class ShellyCover(ShellyBlockEntity, CoverEntity): - """Switch that controls a cover block on Shelly devices.""" +async def async_setup_rpc_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entities for RPC device.""" + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][RPC] + + cover_key_ids = get_rpc_key_ids(wrapper.device.status, "cover") + + if not cover_key_ids: + return + + async_add_entities(RpcShellyCover(wrapper, id_) for id_ in cover_key_ids) + + +class BlockShellyCover(ShellyBlockEntity, CoverEntity): + """Entity that controls a cover on block based Shelly devices.""" _attr_device_class = CoverDeviceClass.SHUTTER def __init__(self, wrapper: BlockDeviceWrapper, block: Block) -> None: - """Initialize light.""" + """Initialize block cover.""" super().__init__(wrapper, block) self.control_result: dict[str, Any] | None = None self._attr_supported_features: int = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP @@ -110,3 +139,61 @@ class ShellyCover(ShellyBlockEntity, CoverEntity): """When device updates, clear control result that overrides state.""" self.control_result = None super()._update_callback() + + +class RpcShellyCover(ShellyRpcEntity, CoverEntity): + """Entity that controls a cover on RPC based Shelly devices.""" + + _attr_device_class = CoverDeviceClass.SHUTTER + + def __init__(self, wrapper: RpcDeviceWrapper, id_: int) -> None: + """Initialize rpc cover.""" + super().__init__(wrapper, f"cover:{id_}") + self._id = id_ + self._attr_supported_features: int = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP + if self.status["pos_control"]: + self._attr_supported_features |= SUPPORT_SET_POSITION + + @property + def is_closed(self) -> bool | None: + """If cover is closed.""" + if not self.status["pos_control"]: + return None + + return cast(bool, self.status["state"] == "closed") + + @property + def current_cover_position(self) -> int | None: + """Position of the cover.""" + if not self.status["pos_control"]: + return None + + return cast(int, self.status["current_pos"]) + + @property + def is_closing(self) -> bool: + """Return if the cover is closing.""" + return cast(bool, self.status["state"] == "closing") + + @property + def is_opening(self) -> bool: + """Return if the cover is opening.""" + return cast(bool, self.status["state"] == "opening") + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close cover.""" + await self.call_rpc("Cover.Close", {"id": self._id}) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open cover.""" + await self.call_rpc("Cover.Open", {"id": self._id}) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + await self.call_rpc( + "Cover.GoToPosition", {"id": self._id, "pos": kwargs[ATTR_POSITION]} + ) + + async def async_stop_cover(self, **_kwargs: Any) -> None: + """Stop the cover.""" + await self.call_rpc("Cover.Stop", {"id": self._id}) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 51e0711b035b59b813f77f28b6f51252182eb31e..85abd67c069862188b0c0915d5af5872d4aa8f87 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -351,6 +351,11 @@ class ShellyRpcEntity(entity.Entity): """Available.""" return self.wrapper.device.connected + @property + def status(self) -> dict: + """Device status by entity key.""" + return cast(dict, self.wrapper.device.status[self.key]) + async def async_added_to_hass(self) -> None: """When entity is added to HASS.""" self.async_on_remove(self.wrapper.async_add_listener(self._update_callback)) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 5391f3f74fe45dd84a0049a2ff0ff46c125c44b9..c0e0bbadda233eb6cef3417e5aa12504819fe92c 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -58,6 +58,7 @@ MOCK_BLOCKS = [ MOCK_CONFIG = { "input:0": {"id": 0, "type": "button"}, "switch:0": {"name": "test switch_0"}, + "cover:0": {"name": "test cover_0"}, "sys": { "ui_data": {}, "device": {"name": "Test name"}, @@ -84,6 +85,7 @@ MOCK_STATUS_COAP = { MOCK_STATUS_RPC = { "switch:0": {"output": True}, + "cover:0": {"state": "stopped", "pos_control": True, "current_pos": 50}, "sys": { "available_updates": { "beta": {"version": "some_beta_version"}, diff --git a/tests/components/shelly/test_cover.py b/tests/components/shelly/test_cover.py index 5c34fcc1bf57e0bb5fd0559861925285333348a2..f5b25ce6cb525e6e3eee831d960022c61de9f46d 100644 --- a/tests/components/shelly/test_cover.py +++ b/tests/components/shelly/test_cover.py @@ -12,13 +12,13 @@ from homeassistant.components.cover import ( STATE_OPEN, STATE_OPENING, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN ROLLER_BLOCK_ID = 1 -async def test_services(hass, coap_wrapper, monkeypatch): - """Test device turn on/off services.""" +async def test_block_device_services(hass, coap_wrapper, monkeypatch): + """Test block device cover services.""" assert coap_wrapper monkeypatch.setitem(coap_wrapper.device.settings, "mode", "roller") @@ -61,8 +61,8 @@ async def test_services(hass, coap_wrapper, monkeypatch): assert hass.states.get("cover.test_name").state == STATE_CLOSED -async def test_update(hass, coap_wrapper, monkeypatch): - """Test device update.""" +async def test_block_device_update(hass, coap_wrapper, monkeypatch): + """Test block device update.""" assert coap_wrapper hass.async_create_task( @@ -81,8 +81,8 @@ async def test_update(hass, coap_wrapper, monkeypatch): assert hass.states.get("cover.test_name").state == STATE_OPEN -async def test_no_roller_blocks(hass, coap_wrapper, monkeypatch): - """Test device without roller blocks.""" +async def test_block_device_no_roller_blocks(hass, coap_wrapper, monkeypatch): + """Test block device without roller blocks.""" assert coap_wrapper monkeypatch.setattr(coap_wrapper.device.blocks[ROLLER_BLOCK_ID], "type", None) @@ -91,3 +91,101 @@ async def test_no_roller_blocks(hass, coap_wrapper, monkeypatch): ) await hass.async_block_till_done() assert hass.states.get("cover.test_name") is None + + +async def test_rpc_device_services(hass, rpc_wrapper, monkeypatch): + """Test RPC device cover services.""" + assert rpc_wrapper + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, COVER_DOMAIN) + ) + await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.test_cover_0", ATTR_POSITION: 50}, + blocking=True, + ) + state = hass.states.get("cover.test_cover_0") + assert state.attributes[ATTR_CURRENT_POSITION] == 50 + + monkeypatch.setitem(rpc_wrapper.device.status["cover:0"], "state", "opening") + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_cover_0"}, + blocking=True, + ) + rpc_wrapper.async_set_updated_data("") + assert hass.states.get("cover.test_cover_0").state == STATE_OPENING + + monkeypatch.setitem(rpc_wrapper.device.status["cover:0"], "state", "closing") + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test_cover_0"}, + blocking=True, + ) + rpc_wrapper.async_set_updated_data("") + assert hass.states.get("cover.test_cover_0").state == STATE_CLOSING + + monkeypatch.setitem(rpc_wrapper.device.status["cover:0"], "state", "closed") + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: "cover.test_cover_0"}, + blocking=True, + ) + rpc_wrapper.async_set_updated_data("") + assert hass.states.get("cover.test_cover_0").state == STATE_CLOSED + + +async def test_rpc_device_no_cover_keys(hass, rpc_wrapper, monkeypatch): + """Test RPC device without cover keys.""" + assert rpc_wrapper + + monkeypatch.delitem(rpc_wrapper.device.status, "cover:0") + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, COVER_DOMAIN) + ) + await hass.async_block_till_done() + assert hass.states.get("cover.test_cover_0") is None + + +async def test_rpc_device_update(hass, rpc_wrapper, monkeypatch): + """Test RPC device update.""" + assert rpc_wrapper + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, COVER_DOMAIN) + ) + await hass.async_block_till_done() + + monkeypatch.setitem(rpc_wrapper.device.status["cover:0"], "state", "closed") + await hass.helpers.entity_component.async_update_entity("cover.test_cover_0") + await hass.async_block_till_done() + assert hass.states.get("cover.test_cover_0").state == STATE_CLOSED + + monkeypatch.setitem(rpc_wrapper.device.status["cover:0"], "state", "open") + await hass.helpers.entity_component.async_update_entity("cover.test_cover_0") + await hass.async_block_till_done() + assert hass.states.get("cover.test_cover_0").state == STATE_OPEN + + +async def test_rpc_device_no_position_control(hass, rpc_wrapper, monkeypatch): + """Test RPC device with no position control.""" + assert rpc_wrapper + + monkeypatch.setitem(rpc_wrapper.device.status["cover:0"], "pos_control", False) + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(rpc_wrapper.entry, COVER_DOMAIN) + ) + await hass.async_block_till_done() + + await hass.helpers.entity_component.async_update_entity("cover.test_cover_0") + await hass.async_block_till_done() + assert hass.states.get("cover.test_cover_0").state == STATE_UNKNOWN