From 94df0844b38aecb3b01f49da5d3f28b250e19f16 Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Tue, 29 Mar 2022 20:07:23 -0400 Subject: [PATCH] Add lock groups (#68857) --- homeassistant/components/group/__init__.py | 1 + homeassistant/components/group/config_flow.py | 14 +- homeassistant/components/group/lock.py | 186 ++++++++++ homeassistant/components/group/strings.json | 17 +- .../components/group/translations/en.json | 15 + .../group/fixtures/configuration.yaml | 12 + tests/components/group/test_config_flow.py | 4 + tests/components/group/test_lock.py | 336 ++++++++++++++++++ 8 files changed, 583 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/group/lock.py create mode 100644 tests/components/group/test_lock.py diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 8395c208ec1..7b895f9c1ce 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -65,6 +65,7 @@ PLATFORMS = [ Platform.COVER, Platform.FAN, Platform.LIGHT, + Platform.LOCK, Platform.MEDIA_PLAYER, Platform.NOTIFY, Platform.SWITCH, diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index e2e31742460..54ed415c91a 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -67,7 +67,15 @@ BINARY_SENSOR_CONFIG_SCHEMA = vol.Schema( {vol.Required("name"): selector.selector({"text": {}})} ).extend(BINARY_SENSOR_OPTIONS_SCHEMA.schema) -GROUP_TYPES = ["binary_sensor", "cover", "fan", "light", "media_player", "switch"] +GROUP_TYPES = [ + "binary_sensor", + "cover", + "fan", + "light", + "lock", + "media_player", + "switch", +] @callback @@ -99,6 +107,9 @@ CONFIG_FLOW: dict[str, HelperFlowFormStep | HelperFlowMenuStep] = { "light": HelperFlowFormStep( basic_group_config_schema("light"), set_group_type("light") ), + "lock": HelperFlowFormStep( + basic_group_config_schema("lock"), set_group_type("lock") + ), "media_player": HelperFlowFormStep( basic_group_config_schema("media_player"), set_group_type("media_player") ), @@ -114,6 +125,7 @@ OPTIONS_FLOW: dict[str, HelperFlowFormStep | HelperFlowMenuStep] = { "cover": HelperFlowFormStep(basic_group_options_schema("cover")), "fan": HelperFlowFormStep(basic_group_options_schema("fan")), "light": HelperFlowFormStep(LIGHT_OPTIONS_SCHEMA), + "lock": HelperFlowFormStep(basic_group_options_schema("lock")), "media_player": HelperFlowFormStep(basic_group_options_schema("media_player")), "switch": HelperFlowFormStep(SWITCH_OPTIONS_SCHEMA), } diff --git a/homeassistant/components/group/lock.py b/homeassistant/components/group/lock.py new file mode 100644 index 00000000000..fe9503137c6 --- /dev/null +++ b/homeassistant/components/group/lock.py @@ -0,0 +1,186 @@ +"""This platform allows several locks to be grouped into one lock.""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components.lock import DOMAIN, PLATFORM_SCHEMA, LockEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_ENTITIES, + CONF_NAME, + CONF_UNIQUE_ID, + SERVICE_LOCK, + SERVICE_OPEN, + SERVICE_UNLOCK, + STATE_JAMMED, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + STATE_UNLOCKING, +) +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from . import GroupEntity + +DEFAULT_NAME = "Lock Group" + +# No limit on parallel updates to enable a group calling another group +PARALLEL_UPDATES = 0 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ENTITIES): cv.entities_domain(DOMAIN), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Lock Group platform.""" + async_add_entities( + [ + LockGroup( + config.get(CONF_UNIQUE_ID), + config[CONF_NAME], + config[CONF_ENTITIES], + ) + ] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize Lock Group config entry.""" + registry = er.async_get(hass) + entities = er.async_validate_entity_ids( + registry, config_entry.options[CONF_ENTITIES] + ) + async_add_entities( + [ + LockGroup( + config_entry.entry_id, + config_entry.title, + entities, + ) + ] + ) + + +class LockGroup(GroupEntity, LockEntity): + """Representation of a lock group.""" + + _attr_available = False + _attr_should_poll = False + + def __init__( + self, + unique_id: str | None, + name: str, + entity_ids: list[str], + ) -> None: + """Initialize a lock group.""" + self._entity_ids = entity_ids + + self._attr_name = name + self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids} + self._attr_unique_id = unique_id + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + + @callback + def async_state_changed_listener(event: Event) -> None: + """Handle child updates.""" + self.async_set_context(event.context) + self.async_defer_or_update_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, self._entity_ids, async_state_changed_listener + ) + ) + + await super().async_added_to_hass() + + async def async_lock(self, **kwargs: Any) -> None: + """Forward the lock command to all locks in the group.""" + data = {ATTR_ENTITY_ID: self._entity_ids} + _LOGGER.debug("Forwarded lock command: %s", data) + + await self.hass.services.async_call( + DOMAIN, + SERVICE_LOCK, + data, + blocking=True, + context=self._context, + ) + + async def async_unlock(self, **kwargs: Any) -> None: + """Forward the unlock command to all locks in the group.""" + data = {ATTR_ENTITY_ID: self._entity_ids} + await self.hass.services.async_call( + DOMAIN, + SERVICE_UNLOCK, + data, + blocking=True, + context=self._context, + ) + + async def async_open(self, **kwargs: Any) -> None: + """Forward the open command to all locks in the group.""" + data = {ATTR_ENTITY_ID: self._entity_ids} + await self.hass.services.async_call( + DOMAIN, + SERVICE_OPEN, + data, + blocking=True, + context=self._context, + ) + + @callback + def async_update_group_state(self) -> None: + """Query all members and determine the lock group state.""" + states = [ + state.state + for entity_id in self._entity_ids + if (state := self.hass.states.get(entity_id)) is not None + ] + + valid_state = all( + state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states + ) + + if not valid_state: + # Set as unknown if any member is unknown or unavailable + self._attr_is_jammed = None + self._attr_is_locking = None + self._attr_is_unlocking = None + self._attr_is_locked = None + else: + # Set attributes based on member states and let the lock entity sort out the correct state + self._attr_is_jammed = STATE_JAMMED in states + self._attr_is_locking = STATE_LOCKING in states + self._attr_is_unlocking = STATE_UNLOCKING in states + self._attr_is_locked = all(state == STATE_LOCKED for state in states) + + self._attr_available = any(state != STATE_UNAVAILABLE for state in states) diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index 383489f37de..c4c4e5d98fe 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -10,6 +10,7 @@ "cover": "Cover group", "fan": "Fan group", "light": "Light group", + "lock": "Lock group", "media_player": "Media player group", "switch": "Switch group" } @@ -48,6 +49,14 @@ "name": "[%key:component::group::config::step::binary_sensor::data::name%]" } }, + "lock": { + "title": "[%key:component::group::config::step::user::title%]", + "data": { + "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", + "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", + "name": "[%key:component::group::config::step::binary_sensor::data::name%]" + } + }, "media_player": { "title": "[%key:component::group::config::step::user::title%]", "data": { @@ -96,6 +105,12 @@ "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]" } }, + "lock": { + "data": { + "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", + "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]" + } + }, "media_player": { "data": { "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", @@ -126,4 +141,4 @@ "problem": "[%key:component::binary_sensor::state::problem::on%]" } } -} +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/en.json b/homeassistant/components/group/translations/en.json index 322f8e2fa10..74e96d05684 100644 --- a/homeassistant/components/group/translations/en.json +++ b/homeassistant/components/group/translations/en.json @@ -35,6 +35,14 @@ }, "title": "New Group" }, + "lock": { + "data": { + "entities": "Members", + "hide_members": "Hide members", + "name": "Name" + }, + "title": "New Group" + }, "media_player": { "data": { "entities": "Members", @@ -58,6 +66,7 @@ "cover": "Cover group", "fan": "Fan group", "light": "Light group", + "lock": "Lock group", "media_player": "Media player group", "switch": "Switch group" }, @@ -95,6 +104,12 @@ }, "description": "If \"all entities\" is enabled, the group's state is on only if all members are on. If \"all entities\" is disabled, the group's state is on if any member is on." }, + "lock": { + "data": { + "entities": "Members", + "hide_members": "Hide members" + } + }, "media_player": { "data": { "entities": "Members", diff --git a/tests/components/group/fixtures/configuration.yaml b/tests/components/group/fixtures/configuration.yaml index 1e88cd6e217..7b3d3c2cd9c 100644 --- a/tests/components/group/fixtures/configuration.yaml +++ b/tests/components/group/fixtures/configuration.yaml @@ -10,6 +10,18 @@ light: - light.outside_patio_lights - light.outside_patio_lights_2 +lock: + - platform: group + name: Inside Locks G + entities: + - lock.front_lock + - lock.back_lock + - platform: group + name: Outside Locks G + entities: + - lock.outside_lock + - lock.outside_lock_2 + switch: - platform: group name: Master Switches G diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 72684a0fc26..d6eba2d98c0 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -24,6 +24,7 @@ from tests.common import MockConfigEntry ("cover", "open", "open", {}, {}, {}, {}), ("fan", "on", "on", {}, {}, {}, {}), ("light", "on", "on", {}, {}, {}, {}), + ("lock", "locked", "locked", {}, {}, {}, {}), ("media_player", "on", "on", {}, {}, {}, {}), ("switch", "on", "on", {}, {}, {}, {}), ), @@ -108,6 +109,7 @@ async def test_config_flow( ("cover", {}), ("fan", {}), ("light", {}), + ("lock", {}), ("media_player", {}), ("switch", {}), ), @@ -179,6 +181,7 @@ def get_suggested(schema, key): ("cover", "open", {}), ("fan", "on", {}), ("light", "on", {"all": False}), + ("lock", "locked", {}), ("media_player", "on", {}), ("switch", "on", {"all": False}), ), @@ -351,6 +354,7 @@ async def test_all_options( ("cover", {}), ("fan", {}), ("light", {}), + ("lock", {}), ("media_player", {}), ("switch", {}), ), diff --git a/tests/components/group/test_lock.py b/tests/components/group/test_lock.py new file mode 100644 index 00000000000..8db28fab18e --- /dev/null +++ b/tests/components/group/test_lock.py @@ -0,0 +1,336 @@ +"""The tests for the Group Lock platform.""" +from unittest.mock import patch + +from homeassistant import config as hass_config +from homeassistant.components.demo import lock as demo_lock +from homeassistant.components.group import DOMAIN, SERVICE_RELOAD +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + SERVICE_LOCK, + SERVICE_OPEN, + SERVICE_UNLOCK, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_JAMMED, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + STATE_UNLOCKED, + STATE_UNLOCKING, +) +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.common import get_fixture_path + + +async def test_default_state(hass): + """Test lock group default state.""" + hass.states.async_set("lock.front", "locked") + await async_setup_component( + hass, + LOCK_DOMAIN, + { + LOCK_DOMAIN: { + "platform": DOMAIN, + "entities": ["lock.front", "lock.back"], + "name": "Door Group", + "unique_id": "unique_identifier", + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("lock.door_group") + assert state is not None + assert state.state == STATE_LOCKED + assert state.attributes.get(ATTR_ENTITY_ID) == ["lock.front", "lock.back"] + + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("lock.door_group") + assert entry + assert entry.unique_id == "unique_identifier" + + +async def test_state_reporting(hass): + """Test the state reporting.""" + await async_setup_component( + hass, + LOCK_DOMAIN, + { + LOCK_DOMAIN: { + "platform": DOMAIN, + "entities": ["lock.test1", "lock.test2"], + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + hass.states.async_set("lock.test1", STATE_LOCKED) + hass.states.async_set("lock.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("lock.lock_group").state == STATE_UNKNOWN + + hass.states.async_set("lock.test1", STATE_LOCKED) + hass.states.async_set("lock.test2", STATE_UNLOCKED) + await hass.async_block_till_done() + assert hass.states.get("lock.lock_group").state == STATE_UNLOCKED + + hass.states.async_set("lock.test1", STATE_LOCKED) + hass.states.async_set("lock.test2", STATE_LOCKED) + await hass.async_block_till_done() + assert hass.states.get("lock.lock_group").state == STATE_LOCKED + + hass.states.async_set("lock.test1", STATE_UNLOCKED) + hass.states.async_set("lock.test2", STATE_UNLOCKED) + await hass.async_block_till_done() + assert hass.states.get("lock.lock_group").state == STATE_UNLOCKED + + hass.states.async_set("lock.test1", STATE_UNLOCKED) + hass.states.async_set("lock.test2", STATE_JAMMED) + await hass.async_block_till_done() + assert hass.states.get("lock.lock_group").state == STATE_JAMMED + + hass.states.async_set("lock.test1", STATE_LOCKED) + hass.states.async_set("lock.test2", STATE_UNLOCKING) + await hass.async_block_till_done() + assert hass.states.get("lock.lock_group").state == STATE_UNLOCKING + + hass.states.async_set("lock.test1", STATE_UNLOCKED) + hass.states.async_set("lock.test2", STATE_LOCKING) + await hass.async_block_till_done() + assert hass.states.get("lock.lock_group").state == STATE_LOCKING + + hass.states.async_set("lock.test1", STATE_UNAVAILABLE) + hass.states.async_set("lock.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("lock.lock_group").state == STATE_UNAVAILABLE + + +@patch.object(demo_lock, "LOCK_UNLOCK_DELAY", 0) +async def test_service_calls(hass, enable_custom_integrations): + """Test service calls.""" + await async_setup_component( + hass, + LOCK_DOMAIN, + { + LOCK_DOMAIN: [ + {"platform": "demo"}, + { + "platform": DOMAIN, + "entities": [ + "lock.front_door", + "lock.kitchen_door", + ], + }, + ] + }, + ) + await hass.async_block_till_done() + + group_state = hass.states.get("lock.lock_group") + assert group_state.state == STATE_UNLOCKED + assert hass.states.get("lock.front_door").state == STATE_LOCKED + assert hass.states.get("lock.kitchen_door").state == STATE_UNLOCKED + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_OPEN, + {ATTR_ENTITY_ID: "lock.lock_group"}, + blocking=True, + ) + assert hass.states.get("lock.front_door").state == STATE_UNLOCKED + assert hass.states.get("lock.kitchen_door").state == STATE_UNLOCKED + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + {ATTR_ENTITY_ID: "lock.lock_group"}, + blocking=True, + ) + assert hass.states.get("lock.front_door").state == STATE_LOCKED + assert hass.states.get("lock.kitchen_door").state == STATE_LOCKED + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + {ATTR_ENTITY_ID: "lock.lock_group"}, + blocking=True, + ) + assert hass.states.get("lock.front_door").state == STATE_UNLOCKED + assert hass.states.get("lock.kitchen_door").state == STATE_UNLOCKED + + +async def test_reload(hass): + """Test the ability to reload locks.""" + await async_setup_component( + hass, + LOCK_DOMAIN, + { + LOCK_DOMAIN: [ + {"platform": "demo"}, + { + "platform": DOMAIN, + "entities": [ + "lock.front_door", + "lock.kitchen_door", + ], + }, + ] + }, + ) + await hass.async_block_till_done() + + await hass.async_block_till_done() + await hass.async_start() + + await hass.async_block_till_done() + assert hass.states.get("lock.lock_group").state == STATE_UNLOCKED + + yaml_path = get_fixture_path("configuration.yaml", "group") + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get("lock.lock_group") is None + assert hass.states.get("lock.inside_locks_g") is not None + assert hass.states.get("lock.outside_locks_g") is not None + + +async def test_reload_with_platform_not_setup(hass): + """Test the ability to reload locks.""" + hass.states.async_set("lock.something", STATE_UNLOCKED) + await async_setup_component( + hass, + LOCK_DOMAIN, + { + LOCK_DOMAIN: [ + {"platform": "demo"}, + ] + }, + ) + assert await async_setup_component( + hass, + "group", + { + "group": { + "group_zero": {"entities": "lock.something", "icon": "mdi:work"}, + } + }, + ) + await hass.async_block_till_done() + + yaml_path = get_fixture_path("configuration.yaml", "group") + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get("lock.lock_group") is None + assert hass.states.get("lock.inside_locks_g") is not None + assert hass.states.get("lock.outside_locks_g") is not None + + +async def test_reload_with_base_integration_platform_not_setup(hass): + """Test the ability to reload locks.""" + assert await async_setup_component( + hass, + "group", + { + "group": { + "group_zero": {"entities": "lock.something", "icon": "mdi:work"}, + } + }, + ) + await hass.async_block_till_done() + hass.states.async_set("lock.front_lock", STATE_LOCKED) + hass.states.async_set("lock.back_lock", STATE_UNLOCKED) + + hass.states.async_set("lock.outside_lock", STATE_LOCKED) + hass.states.async_set("lock.outside_lock_2", STATE_LOCKED) + + yaml_path = get_fixture_path("configuration.yaml", "group") + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get("lock.lock_group") is None + assert hass.states.get("lock.inside_locks_g") is not None + assert hass.states.get("lock.outside_locks_g") is not None + assert hass.states.get("lock.inside_locks_g").state == STATE_UNLOCKED + assert hass.states.get("lock.outside_locks_g").state == STATE_LOCKED + + +@patch.object(demo_lock, "LOCK_UNLOCK_DELAY", 0) +async def test_nested_group(hass): + """Test nested lock group.""" + await async_setup_component( + hass, + LOCK_DOMAIN, + { + LOCK_DOMAIN: [ + {"platform": "demo"}, + { + "platform": DOMAIN, + "entities": ["lock.some_group"], + "name": "Nested Group", + }, + { + "platform": DOMAIN, + "entities": [ + "lock.front_door", + "lock.kitchen_door", + ], + "name": "Some Group", + }, + ] + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("lock.some_group") + assert state is not None + assert state.state == STATE_UNLOCKED + assert state.attributes.get(ATTR_ENTITY_ID) == [ + "lock.front_door", + "lock.kitchen_door", + ] + + state = hass.states.get("lock.nested_group") + assert state is not None + assert state.state == STATE_UNLOCKED + assert state.attributes.get(ATTR_ENTITY_ID) == ["lock.some_group"] + + # Test controlling the nested group + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + {ATTR_ENTITY_ID: "lock.nested_group"}, + blocking=True, + ) + assert hass.states.get("lock.front_door").state == STATE_LOCKED + assert hass.states.get("lock.kitchen_door").state == STATE_LOCKED + assert hass.states.get("lock.some_group").state == STATE_LOCKED + assert hass.states.get("lock.nested_group").state == STATE_LOCKED