Control modes for Shelly Cury (#155665)

Co-authored-by: Shay Levy <levyshay1@gmail.com>
This commit is contained in:
Maciej Bieniek
2025-11-09 00:01:56 +01:00
committed by GitHub
parent a0da295143
commit cd86c78750
8 changed files with 343 additions and 37 deletions

View File

@@ -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"
},

View File

@@ -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]
)

View File

@@ -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"

View File

@@ -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),
),
}

View File

@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': None,
'entity_id': 'select.test_name_mode',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'select.test_name_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <ANY>,
'entity_id': 'switch.test_name_away_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_device[cury_gen4][switch.test_name_left_slot-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <ANY>,
'entity_id': 'switch.test_name_away_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_cury_switch_entity[switch.test_name_left_slot-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -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"

View File

@@ -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)