Add lock groups (#68857)

This commit is contained in:
Jason Hunter 2022-03-29 20:07:23 -04:00 committed by GitHub
parent 61f8af8b58
commit 94df0844b3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 583 additions and 2 deletions

View file

@ -65,6 +65,7 @@ PLATFORMS = [
Platform.COVER,
Platform.FAN,
Platform.LIGHT,
Platform.LOCK,
Platform.MEDIA_PLAYER,
Platform.NOTIFY,
Platform.SWITCH,

View file

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

View file

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

View file

@ -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%]"
}
}
}
}

View file

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

View file

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

View file

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

View file

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