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.COVER,
Platform.FAN, Platform.FAN,
Platform.LIGHT, Platform.LIGHT,
Platform.LOCK,
Platform.MEDIA_PLAYER, Platform.MEDIA_PLAYER,
Platform.NOTIFY, Platform.NOTIFY,
Platform.SWITCH, Platform.SWITCH,

View File

@ -67,7 +67,15 @@ BINARY_SENSOR_CONFIG_SCHEMA = vol.Schema(
{vol.Required("name"): selector.selector({"text": {}})} {vol.Required("name"): selector.selector({"text": {}})}
).extend(BINARY_SENSOR_OPTIONS_SCHEMA.schema) ).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 @callback
@ -99,6 +107,9 @@ CONFIG_FLOW: dict[str, HelperFlowFormStep | HelperFlowMenuStep] = {
"light": HelperFlowFormStep( "light": HelperFlowFormStep(
basic_group_config_schema("light"), set_group_type("light") basic_group_config_schema("light"), set_group_type("light")
), ),
"lock": HelperFlowFormStep(
basic_group_config_schema("lock"), set_group_type("lock")
),
"media_player": HelperFlowFormStep( "media_player": HelperFlowFormStep(
basic_group_config_schema("media_player"), set_group_type("media_player") 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")), "cover": HelperFlowFormStep(basic_group_options_schema("cover")),
"fan": HelperFlowFormStep(basic_group_options_schema("fan")), "fan": HelperFlowFormStep(basic_group_options_schema("fan")),
"light": HelperFlowFormStep(LIGHT_OPTIONS_SCHEMA), "light": HelperFlowFormStep(LIGHT_OPTIONS_SCHEMA),
"lock": HelperFlowFormStep(basic_group_options_schema("lock")),
"media_player": HelperFlowFormStep(basic_group_options_schema("media_player")), "media_player": HelperFlowFormStep(basic_group_options_schema("media_player")),
"switch": HelperFlowFormStep(SWITCH_OPTIONS_SCHEMA), "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", "cover": "Cover group",
"fan": "Fan group", "fan": "Fan group",
"light": "Light group", "light": "Light group",
"lock": "Lock group",
"media_player": "Media player group", "media_player": "Media player group",
"switch": "Switch group" "switch": "Switch group"
} }
@ -48,6 +49,14 @@
"name": "[%key:component::group::config::step::binary_sensor::data::name%]" "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": { "media_player": {
"title": "[%key:component::group::config::step::user::title%]", "title": "[%key:component::group::config::step::user::title%]",
"data": { "data": {
@ -96,6 +105,12 @@
"hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]" "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": { "media_player": {
"data": { "data": {
"entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]",

View File

@ -35,6 +35,14 @@
}, },
"title": "New Group" "title": "New Group"
}, },
"lock": {
"data": {
"entities": "Members",
"hide_members": "Hide members",
"name": "Name"
},
"title": "New Group"
},
"media_player": { "media_player": {
"data": { "data": {
"entities": "Members", "entities": "Members",
@ -58,6 +66,7 @@
"cover": "Cover group", "cover": "Cover group",
"fan": "Fan group", "fan": "Fan group",
"light": "Light group", "light": "Light group",
"lock": "Lock group",
"media_player": "Media player group", "media_player": "Media player group",
"switch": "Switch 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." "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": { "media_player": {
"data": { "data": {
"entities": "Members", "entities": "Members",

View File

@ -10,6 +10,18 @@ light:
- light.outside_patio_lights - light.outside_patio_lights
- light.outside_patio_lights_2 - 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: switch:
- platform: group - platform: group
name: Master Switches G name: Master Switches G

View File

@ -24,6 +24,7 @@ from tests.common import MockConfigEntry
("cover", "open", "open", {}, {}, {}, {}), ("cover", "open", "open", {}, {}, {}, {}),
("fan", "on", "on", {}, {}, {}, {}), ("fan", "on", "on", {}, {}, {}, {}),
("light", "on", "on", {}, {}, {}, {}), ("light", "on", "on", {}, {}, {}, {}),
("lock", "locked", "locked", {}, {}, {}, {}),
("media_player", "on", "on", {}, {}, {}, {}), ("media_player", "on", "on", {}, {}, {}, {}),
("switch", "on", "on", {}, {}, {}, {}), ("switch", "on", "on", {}, {}, {}, {}),
), ),
@ -108,6 +109,7 @@ async def test_config_flow(
("cover", {}), ("cover", {}),
("fan", {}), ("fan", {}),
("light", {}), ("light", {}),
("lock", {}),
("media_player", {}), ("media_player", {}),
("switch", {}), ("switch", {}),
), ),
@ -179,6 +181,7 @@ def get_suggested(schema, key):
("cover", "open", {}), ("cover", "open", {}),
("fan", "on", {}), ("fan", "on", {}),
("light", "on", {"all": False}), ("light", "on", {"all": False}),
("lock", "locked", {}),
("media_player", "on", {}), ("media_player", "on", {}),
("switch", "on", {"all": False}), ("switch", "on", {"all": False}),
), ),
@ -351,6 +354,7 @@ async def test_all_options(
("cover", {}), ("cover", {}),
("fan", {}), ("fan", {}),
("light", {}), ("light", {}),
("lock", {}),
("media_player", {}), ("media_player", {}),
("switch", {}), ("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