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 <levyshay1@gmail.com>

* Update tests/components/shelly/test_switch.py

Co-authored-by: Shay Levy <levyshay1@gmail.com>

* Update tests/components/shelly/test_switch.py

Co-authored-by: Shay Levy <levyshay1@gmail.com>

* Update tests/components/shelly/test_switch.py

Co-authored-by: Shay Levy <levyshay1@gmail.com>

* rename switch

* Update tests/components/shelly/test_switch.py

Co-authored-by: Shay Levy <levyshay1@gmail.com>

* Update tests/components/shelly/test_switch.py

Co-authored-by: Shay Levy <levyshay1@gmail.com>

* Update tests/components/shelly/test_switch.py

Co-authored-by: Shay Levy <levyshay1@gmail.com>

* fix ruff

---------

Co-authored-by: Shay Levy <levyshay1@gmail.com>
pull/117558/head
Simone Chemelli 2024-05-16 19:49:49 +09:00 committed by GitHub
parent 53da59a454
commit 32a9cb4b14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 209 additions and 6 deletions

View File

@ -72,6 +72,7 @@ BLOCK_SLEEPING_PLATFORMS: Final = [
Platform.CLIMATE,
Platform.NUMBER,
Platform.SENSOR,
Platform.SWITCH,
]
RPC_PLATFORMS: Final = [
Platform.BINARY_SENSOR,

View File

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

View File

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

View File

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

View File

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