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:
Paulus Schoutsen 2016-09-07 06:59:16 -07:00 committed by GitHub
parent 91028cbc13
commit 35b388edce
8 changed files with 143 additions and 63 deletions

View File

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

View File

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

View File

@ -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] return []
if domain_filter: entity_ids = group.attributes[ATTR_ENTITY_ID]
domain_filter = domain_filter.lower()
if not domain_filter:
return entity_ids
domain_filter = domain_filter.lower() + '.'
return [ent_id for ent_id in entity_ids return [ent_id for ent_id in entity_ids
if ent_id.startswith(domain_filter)] 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 []
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)

View File

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

View File

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

View File

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

View File

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

View File

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