diff --git a/homeassistant/components/schlage/__init__.py b/homeassistant/components/schlage/__init__.py index 95cfd16958c45f..cf95e190e88d6c 100644 --- a/homeassistant/components/schlage/__init__.py +++ b/homeassistant/components/schlage/__init__.py @@ -11,7 +11,7 @@ from .const import DOMAIN, LOGGER from .coordinator import SchlageDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.LOCK, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.LOCK, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json index 4f32ad094c039d..f3612bb96b8e49 100644 --- a/homeassistant/components/schlage/strings.json +++ b/homeassistant/components/schlage/strings.json @@ -15,5 +15,15 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "switch": { + "beeper": { + "name": "Keypress Beep" + }, + "lock_and_leave": { + "name": "1-Touch Locking" + } + } } } diff --git a/homeassistant/components/schlage/switch.py b/homeassistant/components/schlage/switch.py new file mode 100644 index 00000000000000..1a4eeb7bcc79fc --- /dev/null +++ b/homeassistant/components/schlage/switch.py @@ -0,0 +1,123 @@ +"""Platform for Schlage switch integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from functools import partial +from typing import Any + +from pyschlage.lock import Lock + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import SchlageDataUpdateCoordinator +from .entity import SchlageEntity + + +@dataclass +class SchlageSwitchEntityDescriptionMixin: + """Mixin for required keys.""" + + # NOTE: This has to be a mixin because these are required keys. + # SwitchEntityDescription has attributes with default values, + # which means we can't inherit from it because you haven't have + # non-default arguments follow default arguments in an initializer. + + on_fn: Callable[[Lock], None] + off_fn: Callable[[Lock], None] + value_fn: Callable[[Lock], bool] + + +@dataclass +class SchlageSwitchEntityDescription( + SwitchEntityDescription, SchlageSwitchEntityDescriptionMixin +): + """Entity description for a Schlage switch.""" + + +SWITCHES: tuple[SchlageSwitchEntityDescription, ...] = ( + SchlageSwitchEntityDescription( + key="beeper", + translation_key="beeper", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + on_fn=lambda lock: lock.set_beeper(True), + off_fn=lambda lock: lock.set_beeper(False), + value_fn=lambda lock: lock.beeper_enabled, + ), + SchlageSwitchEntityDescription( + key="lock_and_leve", + translation_key="lock_and_leave", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + on_fn=lambda lock: lock.set_lock_and_leave(True), + off_fn=lambda lock: lock.set_lock_and_leave(False), + value_fn=lambda lock: lock.lock_and_leave_enabled, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switches based on a config entry.""" + coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + entities = [] + for device_id in coordinator.data.locks: + for description in SWITCHES: + entities.append( + SchlageSwitch( + coordinator=coordinator, + description=description, + device_id=device_id, + ) + ) + async_add_entities(entities) + + +class SchlageSwitch(SchlageEntity, SwitchEntity): + """Schlage switch entity.""" + + entity_description: SchlageSwitchEntityDescription + + def __init__( + self, + coordinator: SchlageDataUpdateCoordinator, + description: SchlageSwitchEntityDescription, + device_id: str, + ) -> None: + """Initialize a SchlageSwitch.""" + super().__init__(coordinator=coordinator, device_id=device_id) + self.entity_description = description + self._attr_unique_id = f"{device_id}_{self.entity_description.key}" + + @property + def is_on(self) -> bool: + """Return True if the switch is on.""" + return self.entity_description.value_fn(self._lock) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.hass.async_add_executor_job( + partial(self.entity_description.on_fn, self._lock) + ) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.hass.async_add_executor_job( + partial(self.entity_description.off_fn, self._lock) + ) + await self.coordinator.async_request_refresh() diff --git a/tests/components/schlage/conftest.py b/tests/components/schlage/conftest.py index c0be3d28005170..0078e6a5553d63 100644 --- a/tests/components/schlage/conftest.py +++ b/tests/components/schlage/conftest.py @@ -80,6 +80,8 @@ def mock_lock(): is_jammed=False, battery_level=20, firmware_version="1.0", + lock_and_leave_enabled=True, + beeper_enabled=True, ) mock_lock.logs.return_value = [] mock_lock.last_changed_by.return_value = "thumbturn" diff --git a/tests/components/schlage/test_switch.py b/tests/components/schlage/test_switch.py new file mode 100644 index 00000000000000..30e56b0686fdbc --- /dev/null +++ b/tests/components/schlage/test_switch.py @@ -0,0 +1,72 @@ +"""Test schlage switch.""" +from unittest.mock import Mock + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + + +async def test_switch_device_registry( + hass: HomeAssistant, mock_added_config_entry: ConfigEntry +) -> None: + """Test switch is added to device registry.""" + device_registry = dr.async_get(hass) + device = device_registry.async_get_device(identifiers={("schlage", "test")}) + assert device.model == "" + assert device.sw_version == "1.0" + assert device.name == "Vault Door" + assert device.manufacturer == "Schlage" + + +async def test_beeper_services( + hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry +) -> None: + """Test BeeperSwitch services.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + service_data={ATTR_ENTITY_ID: "switch.vault_door_keypress_beep"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_lock.set_beeper.assert_called_once_with(False) + mock_lock.set_beeper.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + service_data={ATTR_ENTITY_ID: "switch.vault_door_keypress_beep"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_lock.set_beeper.assert_called_once_with(True) + + await hass.config_entries.async_unload(mock_added_config_entry.entry_id) + + +async def test_lock_and_leave_services( + hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry +) -> None: + """Test LockAndLeaveSwitch services.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + service_data={ATTR_ENTITY_ID: "switch.vault_door_1_touch_locking"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_lock.set_lock_and_leave.assert_called_once_with(False) + mock_lock.set_lock_and_leave.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + service_data={ATTR_ENTITY_ID: "switch.vault_door_1_touch_locking"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_lock.set_lock_and_leave.assert_called_once_with(True) + + await hass.config_entries.async_unload(mock_added_config_entry.entry_id)