From cd86c78750e14a193f8eef83daefcf1b618a11bd Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 9 Nov 2025 00:01:56 +0100 Subject: [PATCH] Control modes for Shelly Cury (#155665) Co-authored-by: Shay Levy --- homeassistant/components/shelly/icons.json | 7 ++ homeassistant/components/shelly/select.py | 114 +++++++++++------ homeassistant/components/shelly/strings.json | 14 +++ homeassistant/components/shelly/switch.py | 10 ++ .../shelly/snapshots/test_devices.ambr | 115 ++++++++++++++++++ .../shelly/snapshots/test_switch.ambr | 48 ++++++++ tests/components/shelly/test_select.py | 63 +++++++++- tests/components/shelly/test_switch.py | 9 +- 8 files changed, 343 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/shelly/icons.json b/homeassistant/components/shelly/icons.json index 3de9cd0961a..1e538b7854b 100644 --- a/homeassistant/components/shelly/icons.json +++ b/homeassistant/components/shelly/icons.json @@ -70,6 +70,13 @@ } }, "switch": { + "cury_away_mode": { + "default": "mdi:home-outline", + "state": { + "off": "mdi:home-import-outline", + "on": "mdi:home-export-outline" + } + }, "cury_boost": { "default": "mdi:rocket-launch" }, diff --git a/homeassistant/components/shelly/select.py b/homeassistant/components/shelly/select.py index 4274eac9faf..c8c76bd0b2f 100644 --- a/homeassistant/components/shelly/select.py +++ b/homeassistant/components/shelly/select.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Final +from typing import TYPE_CHECKING, Final from aioshelly.const import RPC_GENERATIONS @@ -37,14 +37,92 @@ PARALLEL_UPDATES = 0 class RpcSelectDescription(RpcEntityDescription, SelectEntityDescription): """Class to describe a RPC select entity.""" + method: str + + +class RpcSelect(ShellyRpcAttributeEntity, SelectEntity): + """Represent a RPC select entity.""" + + entity_description: RpcSelectDescription + _id: int + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcSelectDescription, + ) -> None: + """Initialize select.""" + super().__init__(coordinator, key, attribute, description) + + if self.option_map: + self._attr_options = list(self.option_map.values()) + + if hasattr(self, "_attr_name") and description.role != ROLE_GENERIC: + delattr(self, "_attr_name") + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + if isinstance(self.attribute_value, str) and self.option_map: + return self.option_map[self.attribute_value] + + return None + + @rpc_call + async def async_select_option(self, option: str) -> None: + """Change the value.""" + method = getattr(self.coordinator.device, self.entity_description.method) + + if TYPE_CHECKING: + assert method is not None + + if self.reversed_option_map: + await method(self._id, self.reversed_option_map[option]) + else: + await method(self._id, option) + + +class RpcCuryModeSelect(RpcSelect): + """Represent a RPC select entity for Cury modes.""" + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + if self.attribute_value is None: + return "none" + + if TYPE_CHECKING: + assert isinstance(self.attribute_value, str) + + return self.attribute_value + RPC_SELECT_ENTITIES: Final = { + "cury_mode": RpcSelectDescription( + key="cury", + sub_key="mode", + translation_key="cury_mode", + options=[ + "hall", + "bedroom", + "living_room", + "lavatory_room", + "none", + "reception", + "workplace", + ], + method="cury_set_mode", + entity_class=RpcCuryModeSelect, + ), "enum_generic": RpcSelectDescription( key="enum", sub_key="value", removal_condition=lambda config, _status, key: not is_view_for_platform( config, key, SELECT_PLATFORM ), + method="enum_set", role=ROLE_GENERIC, ), } @@ -89,37 +167,3 @@ def _async_setup_rpc_entry( virtual_text_ids, "enum", ) - - -class RpcSelect(ShellyRpcAttributeEntity, SelectEntity): - """Represent a RPC select entity.""" - - entity_description: RpcSelectDescription - _id: int - - def __init__( - self, - coordinator: ShellyRpcCoordinator, - key: str, - attribute: str, - description: RpcSelectDescription, - ) -> None: - """Initialize select.""" - super().__init__(coordinator, key, attribute, description) - - self._attr_options = list(self.option_map.values()) - - @property - def current_option(self) -> str | None: - """Return the selected entity option to represent the entity state.""" - if not isinstance(self.attribute_value, str): - return None - - return self.option_map[self.attribute_value] - - @rpc_call - async def async_select_option(self, option: str) -> None: - """Change the value.""" - await self.coordinator.device.enum_set( - self._id, self.reversed_option_map[option] - ) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 39c06cd7eda..1aadafee5d3 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -162,6 +162,20 @@ } } }, + "select": { + "cury_mode": { + "name": "Mode", + "state": { + "bedroom": "Bedroom", + "hall": "Hall", + "lavatory_room": "Lavatory room", + "living_room": "Living room", + "none": "None", + "reception": "Reception", + "workplace": "Workplace" + } + } + }, "sensor": { "adc": { "name": "ADC" diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index c3c1619f241..10fc7848ea8 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -290,6 +290,16 @@ RPC_SWITCHES = { available=lambda status: (right := status["right"]) is not None and right.get("vial", {}).get("level", -1) != -1, ), + "cury_away_mode": RpcSwitchDescription( + key="cury", + sub_key="away_mode", + name="Away mode", + translation_key="cury_away_mode", + is_on=lambda status: status["away_mode"], + method_on="cury_set_away_mode", + method_off="cury_set_away_mode", + method_params_fn=lambda id, value: (id, value), + ), } diff --git a/tests/components/shelly/snapshots/test_devices.ambr b/tests/components/shelly/snapshots/test_devices.ambr index 17cc33572d4..fd782ff8d2a 100644 --- a/tests/components/shelly/snapshots/test_devices.ambr +++ b/tests/components/shelly/snapshots/test_devices.ambr @@ -262,6 +262,73 @@ 'state': '45', }) # --- +# name: test_device[cury_gen4][select.test_name_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'hall', + 'bedroom', + 'living_room', + 'lavatory_room', + 'none', + 'reception', + 'workplace', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_name_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mode', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cury_mode', + 'unique_id': '123456789ABC-cury:0-cury_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_device[cury_gen4][select.test_name_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test name Mode', + 'options': list([ + 'hall', + 'bedroom', + 'living_room', + 'lavatory_room', + 'none', + 'reception', + 'workplace', + ]), + }), + 'context': , + 'entity_id': 'select.test_name_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'living_room', + }) +# --- # name: test_device[cury_gen4][sensor.test_name_last_restart-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -564,6 +631,54 @@ 'state': '-49', }) # --- +# name: test_device[cury_gen4][switch.test_name_away_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_name_away_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Away mode', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cury_away_mode', + 'unique_id': '123456789ABC-cury:0-cury_away_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_device[cury_gen4][switch.test_name_away_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test name Away mode', + }), + 'context': , + 'entity_id': 'switch.test_name_away_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_device[cury_gen4][switch.test_name_left_slot-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/shelly/snapshots/test_switch.ambr b/tests/components/shelly/snapshots/test_switch.ambr index 704d86720c9..8d79f6904c6 100644 --- a/tests/components/shelly/snapshots/test_switch.ambr +++ b/tests/components/shelly/snapshots/test_switch.ambr @@ -1,4 +1,52 @@ # serializer version: 1 +# name: test_cury_switch_entity[switch.test_name_away_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_name_away_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Away mode', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cury_away_mode', + 'unique_id': '123456789ABC-cury:0-cury_away_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_cury_switch_entity[switch.test_name_away_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test name Away mode', + }), + 'context': , + 'entity_id': 'switch.test_name_away_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_cury_switch_entity[switch.test_name_left_slot-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/shelly/test_select.py b/tests/components/shelly/test_select.py index d99fc9bf85c..26bb3a9cd78 100644 --- a/tests/components/shelly/test_select.py +++ b/tests/components/shelly/test_select.py @@ -14,7 +14,12 @@ from homeassistant.components.select import ( ) from homeassistant.components.shelly.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceRegistry @@ -266,3 +271,59 @@ async def test_select_set_reauth_error( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == entry.entry_id + + +async def test_rpc_cury_mode_select( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test Cury Mode select entity.""" + entity_id = f"{SELECT_PLATFORM}.test_name_mode" + status = {"cury:0": {"id": 0, "mode": "hall"}} + monkeypatch.setattr(mock_rpc_device, "status", status) + await init_integration(hass, 3) + + assert (state := hass.states.get(entity_id)) + assert state.state == "hall" + assert state.attributes.get(ATTR_OPTIONS) == [ + "hall", + "bedroom", + "living_room", + "lavatory_room", + "none", + "reception", + "workplace", + ] + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Test name Mode" + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.unique_id == "123456789ABC-cury:0-cury_mode" + assert entry.translation_key == "cury_mode" + + monkeypatch.setitem(mock_rpc_device.status["cury:0"], "mode", "living_room") + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == "living_room" + + monkeypatch.setitem(mock_rpc_device.status["cury:0"], "mode", "reception") + await hass.services.async_call( + SELECT_PLATFORM, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "reception"}, + blocking=True, + ) + + mock_rpc_device.cury_set_mode.assert_called_once_with(0, "reception") + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == "reception" + + monkeypatch.setitem(mock_rpc_device.status["cury:0"], "mode", None) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == "none" diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index babb15b1123..9fea11ce878 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -829,6 +829,7 @@ async def test_cury_switch_entity( status = { "cury:0": { "id": 0, + "away_mode": False, "slots": { "left": { "intensity": 70, @@ -848,7 +849,13 @@ async def test_cury_switch_entity( monkeypatch.setattr(mock_rpc_device, "status", status) await init_integration(hass, 3) - for entity in ("left_slot", "left_slot_boost", "right_slot", "right_slot_boost"): + for entity in ( + "away_mode", + "left_slot", + "left_slot_boost", + "right_slot", + "right_slot_boost", + ): entity_id = f"{SWITCH_DOMAIN}.test_name_{entity}" state = hass.states.get(entity_id)