mirror of
https://github.com/home-assistant/core.git
synced 2025-07-15 09:17:10 +00:00
Reload groups (#3203)
* Allow reloading groups without restart * Test to make sure automation listeners are removed. * Remove unused imports for group tests * Simplify group config validation * Add prepare_reload function to entity component * Migrate group to use entity_component.prepare_reload * Migrate automation to use entity_component.prepare_reload * Clean up group.get_entity_ids * Use cv.boolean for group config validation
This commit is contained in:
parent
91028cbc13
commit
35b388edce
@ -14,7 +14,7 @@ import voluptuous as vol
|
|||||||
from voluptuous.humanize import humanize_error
|
from voluptuous.humanize import humanize_error
|
||||||
|
|
||||||
import homeassistant.components as core_components
|
import homeassistant.components as core_components
|
||||||
from homeassistant.components import group, persistent_notification
|
from homeassistant.components import persistent_notification
|
||||||
import homeassistant.config as conf_util
|
import homeassistant.config as conf_util
|
||||||
import homeassistant.core as core
|
import homeassistant.core as core
|
||||||
import homeassistant.loader as loader
|
import homeassistant.loader as loader
|
||||||
@ -118,7 +118,7 @@ def _setup_component(hass: core.HomeAssistant, domain: str, config) -> bool:
|
|||||||
|
|
||||||
# Assumption: if a component does not depend on groups
|
# Assumption: if a component does not depend on groups
|
||||||
# it communicates with devices
|
# it communicates with devices
|
||||||
if group.DOMAIN not in getattr(component, 'DEPENDENCIES', []):
|
if 'group' not in getattr(component, 'DEPENDENCIES', []):
|
||||||
hass.pool.add_worker()
|
hass.pool.add_worker()
|
||||||
|
|
||||||
hass.bus.fire(
|
hass.bus.fire(
|
||||||
|
@ -10,8 +10,7 @@ import os
|
|||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.bootstrap import (
|
from homeassistant.bootstrap import prepare_setup_platform
|
||||||
prepare_setup_platform, prepare_setup_component)
|
|
||||||
from homeassistant import config as conf_util
|
from homeassistant import config as conf_util
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_ENTITY_ID, CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF,
|
ATTR_ENTITY_ID, CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF,
|
||||||
@ -183,19 +182,9 @@ def setup(hass, config):
|
|||||||
|
|
||||||
def reload_service_handler(service_call):
|
def reload_service_handler(service_call):
|
||||||
"""Remove all automations and load new ones from config."""
|
"""Remove all automations and load new ones from config."""
|
||||||
try:
|
conf = component.prepare_reload()
|
||||||
path = conf_util.find_config_file(hass.config.config_dir)
|
|
||||||
conf = conf_util.load_yaml_config_file(path)
|
|
||||||
except HomeAssistantError as err:
|
|
||||||
_LOGGER.error(err)
|
|
||||||
return
|
|
||||||
|
|
||||||
conf = prepare_setup_component(hass, conf, DOMAIN)
|
|
||||||
|
|
||||||
if conf is None:
|
if conf is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
component.reset()
|
|
||||||
_process_config(hass, conf, component)
|
_process_config(hass, conf, component)
|
||||||
|
|
||||||
hass.services.register(DOMAIN, SERVICE_TRIGGER, trigger_service_handler,
|
hass.services.register(DOMAIN, SERVICE_TRIGGER, trigger_service_handler,
|
||||||
|
@ -4,17 +4,19 @@ Provides functionality to group entities.
|
|||||||
For more details about this component, please refer to the documentation at
|
For more details about this component, please refer to the documentation at
|
||||||
https://home-assistant.io/components/group/
|
https://home-assistant.io/components/group/
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
from collections import OrderedDict
|
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
import homeassistant.core as ha
|
from homeassistant import config as conf_util, core as ha
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_ENTITY_ID, CONF_ICON, CONF_NAME, STATE_CLOSED, STATE_HOME,
|
ATTR_ENTITY_ID, CONF_ICON, CONF_NAME, STATE_CLOSED, STATE_HOME,
|
||||||
STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_OPEN, STATE_LOCKED,
|
STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_OPEN, STATE_LOCKED,
|
||||||
STATE_UNLOCKED, STATE_UNKNOWN, ATTR_ASSUMED_STATE)
|
STATE_UNLOCKED, STATE_UNKNOWN, ATTR_ASSUMED_STATE)
|
||||||
from homeassistant.helpers.entity import Entity, generate_entity_id
|
from homeassistant.helpers.entity import Entity, generate_entity_id
|
||||||
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
from homeassistant.helpers.event import track_state_change
|
from homeassistant.helpers.event import track_state_change
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
|
||||||
@ -29,36 +31,27 @@ ATTR_AUTO = 'auto'
|
|||||||
ATTR_ORDER = 'order'
|
ATTR_ORDER = 'order'
|
||||||
ATTR_VIEW = 'view'
|
ATTR_VIEW = 'view'
|
||||||
|
|
||||||
|
SERVICE_RELOAD = 'reload'
|
||||||
|
RELOAD_SERVICE_SCHEMA = vol.Schema({})
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _conf_preprocess(value):
|
def _conf_preprocess(value):
|
||||||
"""Preprocess alternative configuration formats."""
|
"""Preprocess alternative configuration formats."""
|
||||||
if isinstance(value, (str, list)):
|
if not isinstance(value, dict):
|
||||||
value = {CONF_ENTITIES: value}
|
value = {CONF_ENTITIES: value}
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
_SINGLE_GROUP_CONFIG = vol.Schema(vol.All(_conf_preprocess, {
|
|
||||||
vol.Optional(CONF_ENTITIES): vol.Any(cv.entity_ids, None),
|
|
||||||
CONF_VIEW: bool,
|
|
||||||
CONF_NAME: str,
|
|
||||||
CONF_ICON: cv.icon,
|
|
||||||
}))
|
|
||||||
|
|
||||||
|
|
||||||
def _group_dict(value):
|
|
||||||
"""Validate a dictionary of group definitions."""
|
|
||||||
config = OrderedDict()
|
|
||||||
for key, group in value.items():
|
|
||||||
try:
|
|
||||||
config[key] = _SINGLE_GROUP_CONFIG(group)
|
|
||||||
except vol.MultipleInvalid as ex:
|
|
||||||
raise vol.Invalid('Group {} is invalid: {}'.format(key, ex))
|
|
||||||
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
CONFIG_SCHEMA = vol.Schema({
|
CONFIG_SCHEMA = vol.Schema({
|
||||||
DOMAIN: vol.All(dict, _group_dict)
|
DOMAIN: {cv.match_all: vol.Schema(vol.All(_conf_preprocess, {
|
||||||
|
vol.Optional(CONF_ENTITIES): vol.Any(cv.entity_ids, None),
|
||||||
|
CONF_VIEW: cv.boolean,
|
||||||
|
CONF_NAME: cv.string,
|
||||||
|
CONF_ICON: cv.icon,
|
||||||
|
}))}
|
||||||
}, extra=vol.ALLOW_EXTRA)
|
}, extra=vol.ALLOW_EXTRA)
|
||||||
|
|
||||||
# List of ON/OFF state tuples for groupable states
|
# List of ON/OFF state tuples for groupable states
|
||||||
@ -88,6 +81,11 @@ def is_on(hass, entity_id):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def reload(hass):
|
||||||
|
"""Reload the automation from config."""
|
||||||
|
hass.services.call(DOMAIN, SERVICE_RELOAD)
|
||||||
|
|
||||||
|
|
||||||
def expand_entity_ids(hass, entity_ids):
|
def expand_entity_ids(hass, entity_ids):
|
||||||
"""Return entity_ids with group entity ids replaced by their members."""
|
"""Return entity_ids with group entity ids replaced by their members."""
|
||||||
found_ids = []
|
found_ids = []
|
||||||
@ -121,35 +119,59 @@ def expand_entity_ids(hass, entity_ids):
|
|||||||
|
|
||||||
def get_entity_ids(hass, entity_id, domain_filter=None):
|
def get_entity_ids(hass, entity_id, domain_filter=None):
|
||||||
"""Get members of this group."""
|
"""Get members of this group."""
|
||||||
entity_id = entity_id.lower()
|
group = hass.states.get(entity_id)
|
||||||
|
|
||||||
try:
|
if not group or ATTR_ENTITY_ID not in group.attributes:
|
||||||
entity_ids = hass.states.get(entity_id).attributes[ATTR_ENTITY_ID]
|
|
||||||
|
|
||||||
if domain_filter:
|
|
||||||
domain_filter = domain_filter.lower()
|
|
||||||
|
|
||||||
return [ent_id for ent_id in entity_ids
|
|
||||||
if ent_id.startswith(domain_filter)]
|
|
||||||
else:
|
|
||||||
return entity_ids
|
|
||||||
|
|
||||||
except (AttributeError, KeyError):
|
|
||||||
# AttributeError if state did not exist
|
|
||||||
# KeyError if key did not exist in attributes
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
entity_ids = group.attributes[ATTR_ENTITY_ID]
|
||||||
|
|
||||||
|
if not domain_filter:
|
||||||
|
return entity_ids
|
||||||
|
|
||||||
|
domain_filter = domain_filter.lower() + '.'
|
||||||
|
|
||||||
|
return [ent_id for ent_id in entity_ids
|
||||||
|
if ent_id.startswith(domain_filter)]
|
||||||
|
|
||||||
|
|
||||||
def setup(hass, config):
|
def setup(hass, config):
|
||||||
"""Setup all groups found definded in the configuration."""
|
"""Setup all groups found definded in the configuration."""
|
||||||
|
component = EntityComponent(_LOGGER, DOMAIN, hass)
|
||||||
|
|
||||||
|
success = _process_config(hass, config, component)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
return False
|
||||||
|
|
||||||
|
descriptions = conf_util.load_yaml_config_file(
|
||||||
|
os.path.join(os.path.dirname(__file__), 'services.yaml'))
|
||||||
|
|
||||||
|
def reload_service_handler(service_call):
|
||||||
|
"""Remove all groups and load new ones from config."""
|
||||||
|
conf = component.prepare_reload()
|
||||||
|
if conf is None:
|
||||||
|
return
|
||||||
|
_process_config(hass, conf, component)
|
||||||
|
|
||||||
|
hass.services.register(DOMAIN, SERVICE_RELOAD, reload_service_handler,
|
||||||
|
descriptions[DOMAIN][SERVICE_RELOAD],
|
||||||
|
schema=RELOAD_SERVICE_SCHEMA)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _process_config(hass, config, component):
|
||||||
|
"""Process group configuration."""
|
||||||
for object_id, conf in config.get(DOMAIN, {}).items():
|
for object_id, conf in config.get(DOMAIN, {}).items():
|
||||||
name = conf.get(CONF_NAME, object_id)
|
name = conf.get(CONF_NAME, object_id)
|
||||||
entity_ids = conf.get(CONF_ENTITIES) or []
|
entity_ids = conf.get(CONF_ENTITIES) or []
|
||||||
icon = conf.get(CONF_ICON)
|
icon = conf.get(CONF_ICON)
|
||||||
view = conf.get(CONF_VIEW)
|
view = conf.get(CONF_VIEW)
|
||||||
|
|
||||||
Group(hass, name, entity_ids, icon=icon, view=view,
|
group = Group(hass, name, entity_ids, icon=icon, view=view,
|
||||||
object_id=object_id)
|
object_id=object_id)
|
||||||
|
component.add_entities((group,))
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -242,17 +264,21 @@ class Group(Entity):
|
|||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""Unregister the group from Home Assistant."""
|
"""Unregister the group from Home Assistant."""
|
||||||
self.hass.states.remove(self.entity_id)
|
self.remove()
|
||||||
|
|
||||||
if self._unsub_state_changed:
|
|
||||||
self._unsub_state_changed()
|
|
||||||
self._unsub_state_changed = None
|
|
||||||
|
|
||||||
def update(self):
|
def update(self):
|
||||||
"""Query all members and determine current group state."""
|
"""Query all members and determine current group state."""
|
||||||
self._state = STATE_UNKNOWN
|
self._state = STATE_UNKNOWN
|
||||||
self._update_group_state()
|
self._update_group_state()
|
||||||
|
|
||||||
|
def remove(self):
|
||||||
|
"""Remove group from HASS."""
|
||||||
|
super().remove()
|
||||||
|
|
||||||
|
if self._unsub_state_changed:
|
||||||
|
self._unsub_state_changed()
|
||||||
|
self._unsub_state_changed = None
|
||||||
|
|
||||||
def _state_changed_listener(self, entity_id, old_state, new_state):
|
def _state_changed_listener(self, entity_id, old_state, new_state):
|
||||||
"""Respond to a member state changing."""
|
"""Respond to a member state changing."""
|
||||||
self._update_group_state(new_state)
|
self._update_group_state(new_state)
|
||||||
|
@ -39,6 +39,11 @@ foursquare:
|
|||||||
description: Vertical accuracy of the user's location, in meters.
|
description: Vertical accuracy of the user's location, in meters.
|
||||||
example: 1
|
example: 1
|
||||||
|
|
||||||
|
group:
|
||||||
|
reload:
|
||||||
|
description: "Reload group configuration."
|
||||||
|
fields:
|
||||||
|
|
||||||
persistent_notification:
|
persistent_notification:
|
||||||
create:
|
create:
|
||||||
description: Show a notification in the frontend
|
description: Show a notification in the frontend
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
"""Helpers for components that manage entities."""
|
"""Helpers for components that manage entities."""
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
|
|
||||||
from homeassistant.bootstrap import prepare_setup_platform
|
from homeassistant import config as conf_util
|
||||||
from homeassistant.components import group
|
from homeassistant.bootstrap import (prepare_setup_platform,
|
||||||
|
prepare_setup_component)
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_ENTITY_ID, CONF_SCAN_INTERVAL, CONF_ENTITY_NAMESPACE,
|
ATTR_ENTITY_ID, CONF_SCAN_INTERVAL, CONF_ENTITY_NAMESPACE,
|
||||||
DEVICE_DEFAULT_NAME)
|
DEVICE_DEFAULT_NAME)
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.loader import get_component
|
||||||
from homeassistant.helpers import config_per_platform, discovery
|
from homeassistant.helpers import config_per_platform, discovery
|
||||||
from homeassistant.helpers.entity import generate_entity_id
|
from homeassistant.helpers.entity import generate_entity_id
|
||||||
from homeassistant.helpers.event import track_utc_time_change
|
from homeassistant.helpers.event import track_utc_time_change
|
||||||
@ -135,6 +138,7 @@ class EntityComponent(object):
|
|||||||
def update_group(self):
|
def update_group(self):
|
||||||
"""Set up and/or update component group."""
|
"""Set up and/or update component group."""
|
||||||
if self.group is None and self.group_name is not None:
|
if self.group is None and self.group_name is not None:
|
||||||
|
group = get_component('group')
|
||||||
self.group = group.Group(self.hass, self.group_name,
|
self.group = group.Group(self.hass, self.group_name,
|
||||||
user_defined=False)
|
user_defined=False)
|
||||||
|
|
||||||
@ -157,6 +161,23 @@ class EntityComponent(object):
|
|||||||
self.group.stop()
|
self.group.stop()
|
||||||
self.group = None
|
self.group = None
|
||||||
|
|
||||||
|
def prepare_reload(self):
|
||||||
|
"""Prepare reloading this entity component."""
|
||||||
|
try:
|
||||||
|
path = conf_util.find_config_file(self.hass.config.config_dir)
|
||||||
|
conf = conf_util.load_yaml_config_file(path)
|
||||||
|
except HomeAssistantError as err:
|
||||||
|
self.logger.error(err)
|
||||||
|
return None
|
||||||
|
|
||||||
|
conf = prepare_setup_component(self.hass, conf, self.domain)
|
||||||
|
|
||||||
|
if conf is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
self.reset()
|
||||||
|
return conf
|
||||||
|
|
||||||
|
|
||||||
class EntityPlatform(object):
|
class EntityPlatform(object):
|
||||||
"""Keep track of entities for a single platform."""
|
"""Keep track of entities for a single platform."""
|
||||||
|
@ -6,11 +6,11 @@ import logging
|
|||||||
import jinja2
|
import jinja2
|
||||||
from jinja2.sandbox import ImmutableSandboxedEnvironment
|
from jinja2.sandbox import ImmutableSandboxedEnvironment
|
||||||
|
|
||||||
from homeassistant.components import group
|
|
||||||
from homeassistant.const import STATE_UNKNOWN, ATTR_LATITUDE, ATTR_LONGITUDE
|
from homeassistant.const import STATE_UNKNOWN, ATTR_LATITUDE, ATTR_LONGITUDE
|
||||||
from homeassistant.core import State
|
from homeassistant.core import State
|
||||||
from homeassistant.exceptions import TemplateError
|
from homeassistant.exceptions import TemplateError
|
||||||
from homeassistant.helpers import location as loc_helper
|
from homeassistant.helpers import location as loc_helper
|
||||||
|
from homeassistant.loader import get_component
|
||||||
from homeassistant.util import convert, dt as dt_util, location as loc_util
|
from homeassistant.util import convert, dt as dt_util, location as loc_util
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@ -169,6 +169,8 @@ class LocationMethods(object):
|
|||||||
else:
|
else:
|
||||||
gr_entity_id = str(entities)
|
gr_entity_id = str(entities)
|
||||||
|
|
||||||
|
group = get_component('group')
|
||||||
|
|
||||||
states = [self._hass.states.get(entity_id) for entity_id
|
states = [self._hass.states.get(entity_id) for entity_id
|
||||||
in group.expand_entity_ids(self._hass, [gr_entity_id])]
|
in group.expand_entity_ids(self._hass, [gr_entity_id])]
|
||||||
|
|
||||||
|
@ -450,6 +450,9 @@ class TestAutomation(unittest.TestCase):
|
|||||||
})
|
})
|
||||||
assert self.hass.states.get('automation.hello') is not None
|
assert self.hass.states.get('automation.hello') is not None
|
||||||
assert self.hass.states.get('automation.bye') is None
|
assert self.hass.states.get('automation.bye') is None
|
||||||
|
listeners = self.hass.bus.listeners
|
||||||
|
assert listeners.get('test_event') == 1
|
||||||
|
assert listeners.get('test_event2') is None
|
||||||
|
|
||||||
self.hass.bus.fire('test_event')
|
self.hass.bus.fire('test_event')
|
||||||
self.hass.pool.block_till_done()
|
self.hass.pool.block_till_done()
|
||||||
@ -462,6 +465,9 @@ class TestAutomation(unittest.TestCase):
|
|||||||
|
|
||||||
assert self.hass.states.get('automation.hello') is None
|
assert self.hass.states.get('automation.hello') is None
|
||||||
assert self.hass.states.get('automation.bye') is not None
|
assert self.hass.states.get('automation.bye') is not None
|
||||||
|
listeners = self.hass.bus.listeners
|
||||||
|
assert listeners.get('test_event') is None
|
||||||
|
assert listeners.get('test_event2') == 1
|
||||||
|
|
||||||
self.hass.bus.fire('test_event')
|
self.hass.bus.fire('test_event')
|
||||||
self.hass.pool.block_till_done()
|
self.hass.pool.block_till_done()
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
"""The tests for the Group components."""
|
"""The tests for the Group components."""
|
||||||
# pylint: disable=protected-access,too-many-public-methods
|
# pylint: disable=protected-access,too-many-public-methods
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from homeassistant.bootstrap import _setup_component
|
from homeassistant.bootstrap import _setup_component
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
@ -308,3 +309,33 @@ class TestComponentsGroup(unittest.TestCase):
|
|||||||
self.assertEqual(STATE_NOT_HOME,
|
self.assertEqual(STATE_NOT_HOME,
|
||||||
self.hass.states.get(
|
self.hass.states.get(
|
||||||
group.ENTITY_ID_FORMAT.format('peeps')).state)
|
group.ENTITY_ID_FORMAT.format('peeps')).state)
|
||||||
|
|
||||||
|
def test_reloading_groups(self):
|
||||||
|
"""Test reloading the group config."""
|
||||||
|
_setup_component(self.hass, 'group', {'group': {
|
||||||
|
'second_group': {
|
||||||
|
'entities': 'light.Bowl',
|
||||||
|
'icon': 'mdi:work',
|
||||||
|
'view': True,
|
||||||
|
},
|
||||||
|
'test_group': 'hello.world,sensor.happy',
|
||||||
|
'empty_group': {'name': 'Empty Group', 'entities': None},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert sorted(self.hass.states.entity_ids()) == \
|
||||||
|
['group.empty_group', 'group.second_group', 'group.test_group']
|
||||||
|
assert self.hass.bus.listeners['state_changed'] == 3
|
||||||
|
|
||||||
|
with patch('homeassistant.config.load_yaml_config_file', return_value={
|
||||||
|
'group': {
|
||||||
|
'hello': {
|
||||||
|
'entities': 'light.Bowl',
|
||||||
|
'icon': 'mdi:work',
|
||||||
|
'view': True,
|
||||||
|
}}}):
|
||||||
|
group.reload(self.hass)
|
||||||
|
self.hass.pool.block_till_done()
|
||||||
|
|
||||||
|
assert self.hass.states.entity_ids() == ['group.hello']
|
||||||
|
assert self.hass.bus.listeners['state_changed'] == 1
|
||||||
|
Loading…
x
Reference in New Issue
Block a user