From 32a9cb4b14f1819430fcfd3be4979acaddcb42cf Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 16 May 2024 19:49:49 +0900 Subject: [PATCH] Add Shelly motion sensor switch (#115312) * Add Shelly motion sensor switch * update name * make motion switch a restore entity * add test * apply review comment * Update tests/components/shelly/test_switch.py Co-authored-by: Shay Levy * Update tests/components/shelly/test_switch.py Co-authored-by: Shay Levy * Update tests/components/shelly/test_switch.py Co-authored-by: Shay Levy * Update tests/components/shelly/test_switch.py Co-authored-by: Shay Levy * rename switch * Update tests/components/shelly/test_switch.py Co-authored-by: Shay Levy * Update tests/components/shelly/test_switch.py Co-authored-by: Shay Levy * Update tests/components/shelly/test_switch.py Co-authored-by: Shay Levy * fix ruff --------- Co-authored-by: Shay Levy --- homeassistant/components/shelly/__init__.py | 1 + homeassistant/components/shelly/const.py | 5 + homeassistant/components/shelly/switch.py | 77 +++++++++++- tests/components/shelly/conftest.py | 2 +- tests/components/shelly/test_switch.py | 130 +++++++++++++++++++- 5 files changed, 209 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 2c6a2e4caad..5c5b97bcbe0 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -72,6 +72,7 @@ BLOCK_SLEEPING_PLATFORMS: Final = [ Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR, + Platform.SWITCH, ] RPC_PLATFORMS: Final = [ Platform.BINARY_SENSOR, diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 70dc60c4ad9..fcc7cc44af9 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -45,6 +45,11 @@ RGBW_MODELS: Final = ( MODEL_RGBW2, ) +MOTION_MODELS: Final = ( + MODEL_MOTION, + MODEL_MOTION_2, +) + MODELS_SUPPORTING_LIGHT_TRANSITION: Final = ( MODEL_DUO, MODEL_BULB_RGBW, diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 70b6754608b..eda61e44d84 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -22,18 +22,23 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import STATE_ON, EntityCategory +from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.restore_state import RestoreEntity -from .const import DOMAIN, GAS_VALVE_OPEN_STATES +from .const import CONF_SLEEP_PERIOD, DOMAIN, GAS_VALVE_OPEN_STATES, MOTION_MODELS from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, ShellyBlockAttributeEntity, ShellyBlockEntity, ShellyRpcEntity, + ShellySleepingBlockAttributeEntity, async_setup_block_attribute_entities, + async_setup_entry_attribute_entities, ) from .utils import ( async_remove_shelly_entity, @@ -60,6 +65,12 @@ GAS_VALVE_SWITCH = BlockSwitchDescription( entity_registry_enabled_default=False, ) +MOTION_SWITCH = BlockSwitchDescription( + key="sensor|motionActive", + name="Motion detection", + entity_category=EntityCategory.CONFIG, +) + async def async_setup_entry( hass: HomeAssistant, @@ -94,6 +105,20 @@ def async_setup_block_entry( ) return + # Add Shelly Motion as a switch + if coordinator.model in MOTION_MODELS: + async_setup_entry_attribute_entities( + hass, + config_entry, + async_add_entities, + {("sensor", "motionActive"): MOTION_SWITCH}, + BlockSleepingMotionSwitch, + ) + return + + if config_entry.data[CONF_SLEEP_PERIOD]: + return + # In roller mode the relay blocks exist but do not contain required info if ( coordinator.model in [MODEL_2, MODEL_25] @@ -165,6 +190,54 @@ def async_setup_rpc_entry( async_add_entities(RpcRelaySwitch(coordinator, id_) for id_ in switch_ids) +class BlockSleepingMotionSwitch( + ShellySleepingBlockAttributeEntity, RestoreEntity, SwitchEntity +): + """Entity that controls Motion Sensor on Block based Shelly devices.""" + + entity_description: BlockSwitchDescription + _attr_translation_key = "motion_switch" + + def __init__( + self, + coordinator: ShellyBlockCoordinator, + block: Block | None, + attribute: str, + description: BlockSwitchDescription, + entry: RegistryEntry | None = None, + ) -> None: + """Initialize the sleeping sensor.""" + super().__init__(coordinator, block, attribute, description, entry) + self.last_state: State | None = None + + @property + def is_on(self) -> bool | None: + """If motion is active.""" + if self.block is not None: + return bool(self.block.motionActive) + + if self.last_state is None: + return None + + return self.last_state.state == STATE_ON + + async def async_turn_on(self, **kwargs: Any) -> None: + """Activate switch.""" + await self.coordinator.device.set_shelly_motion_detection(True) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Deactivate switch.""" + await self.coordinator.device.set_shelly_motion_detection(False) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_state()) is not None: + self.last_state = last_state + + class BlockValveSwitch(ShellyBlockAttributeEntity, SwitchEntity): """Entity that controls a Gas Valve on Block based Shelly devices. diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 18813ff7eba..ad940b8fd27 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -122,7 +122,7 @@ MOCK_BLOCKS = [ set_state=AsyncMock(side_effect=mock_light_set_state), ), Mock( - sensor_ids={"motion": 0, "temp": 22.1, "gas": "mild"}, + sensor_ids={"motion": 0, "temp": 22.1, "gas": "mild", "motionActive": 1}, channel="0", motion=0, temp=22.1, diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index dd214c8841d..e6e8bbd0f71 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -11,7 +11,11 @@ from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.script import scripts_with_entity -from homeassistant.components.shelly.const import DOMAIN, MODEL_WALL_DISPLAY +from homeassistant.components.shelly.const import ( + DOMAIN, + MODEL_WALL_DISPLAY, + MOTION_MODELS, +) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( @@ -20,17 +24,22 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component -from . import init_integration, register_entity +from . import get_entity_state, init_integration, register_device, register_entity + +from tests.common import mock_restore_cache RELAY_BLOCK_ID = 0 GAS_VALVE_BLOCK_ID = 6 +MOTION_BLOCK_ID = 3 async def test_block_device_services( @@ -56,6 +65,121 @@ async def test_block_device_services( assert hass.states.get("switch.test_name_channel_1").state == STATE_OFF +@pytest.mark.parametrize("model", MOTION_MODELS) +async def test_block_motion_switch( + hass: HomeAssistant, + model: str, + mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test Shelly motion active turn on/off services.""" + entity_id = "switch.test_name_motion_detection" + await init_integration(hass, 1, sleep_period=1000, model=model) + + # Make device online + mock_block_device.mock_online() + await hass.async_block_till_done(wait_background_tasks=True) + + assert get_entity_state(hass, entity_id) == STATE_ON + + # turn off + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + monkeypatch.setattr(mock_block_device.blocks[MOTION_BLOCK_ID], "motionActive", 0) + mock_block_device.mock_update() + + mock_block_device.set_shelly_motion_detection.assert_called_once_with(False) + assert get_entity_state(hass, entity_id) == STATE_OFF + + # turn on + mock_block_device.set_shelly_motion_detection.reset_mock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + monkeypatch.setattr(mock_block_device.blocks[MOTION_BLOCK_ID], "motionActive", 1) + mock_block_device.mock_update() + + mock_block_device.set_shelly_motion_detection.assert_called_once_with(True) + assert get_entity_state(hass, entity_id) == STATE_ON + + +@pytest.mark.parametrize("model", MOTION_MODELS) +async def test_block_restored_motion_switch( + hass: HomeAssistant, + model: str, + mock_block_device: Mock, + device_reg: DeviceRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test block restored motion active switch.""" + entry = await init_integration( + hass, 1, sleep_period=1000, model=model, skip_setup=True + ) + register_device(device_reg, entry) + entity_id = register_entity( + hass, + SWITCH_DOMAIN, + "test_name_motion_detection", + "sensor_0-motionActive", + entry, + ) + + mock_restore_cache(hass, [State(entity_id, STATE_OFF)]) + monkeypatch.setattr(mock_block_device, "initialized", False) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert get_entity_state(hass, entity_id) == STATE_OFF + + # Make device online + monkeypatch.setattr(mock_block_device, "initialized", True) + mock_block_device.mock_online() + await hass.async_block_till_done(wait_background_tasks=True) + + assert get_entity_state(hass, entity_id) == STATE_ON + + +@pytest.mark.parametrize("model", MOTION_MODELS) +async def test_block_restored_motion_switch_no_last_state( + hass: HomeAssistant, + model: str, + mock_block_device: Mock, + device_reg: DeviceRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test block restored motion active switch missing last state.""" + entry = await init_integration( + hass, 1, sleep_period=1000, model=model, skip_setup=True + ) + register_device(device_reg, entry) + entity_id = register_entity( + hass, + SWITCH_DOMAIN, + "test_name_motion_detection", + "sensor_0-motionActive", + entry, + ) + monkeypatch.setattr(mock_block_device, "initialized", False) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert get_entity_state(hass, entity_id) == STATE_UNKNOWN + + # Make device online + monkeypatch.setattr(mock_block_device, "initialized", True) + mock_block_device.mock_online() + await hass.async_block_till_done(wait_background_tasks=True) + + assert get_entity_state(hass, entity_id) == STATE_ON + + async def test_block_device_unique_ids( hass: HomeAssistant, entity_registry: EntityRegistry, mock_block_device: Mock ) -> None: