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
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.core as core
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
# it communicates with devices
if group.DOMAIN not in getattr(component, 'DEPENDENCIES', []):
if 'group' not in getattr(component, 'DEPENDENCIES', []):
hass.pool.add_worker()
hass.bus.fire(

View File

@ -10,8 +10,7 @@ import os
import voluptuous as vol
from homeassistant.bootstrap import (
prepare_setup_platform, prepare_setup_component)
from homeassistant.bootstrap import prepare_setup_platform
from homeassistant import config as conf_util
from homeassistant.const import (
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):
"""Remove all automations and load new ones from config."""
try:
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)
conf = component.prepare_reload()
if conf is None:
return
component.reset()
_process_config(hass, conf, component)
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
https://home-assistant.io/components/group/
"""
import logging
import os
import threading
from collections import OrderedDict
import voluptuous as vol
import homeassistant.core as ha
from homeassistant import config as conf_util, core as ha
from homeassistant.const import (
ATTR_ENTITY_ID, CONF_ICON, CONF_NAME, STATE_CLOSED, STATE_HOME,
STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_OPEN, STATE_LOCKED,
STATE_UNLOCKED, STATE_UNKNOWN, ATTR_ASSUMED_STATE)
from homeassistant.helpers.entity import Entity, generate_entity_id
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import track_state_change
import homeassistant.helpers.config_validation as cv
@ -29,36 +31,27 @@ ATTR_AUTO = 'auto'
ATTR_ORDER = 'order'
ATTR_VIEW = 'view'
SERVICE_RELOAD = 'reload'
RELOAD_SERVICE_SCHEMA = vol.Schema({})
_LOGGER = logging.getLogger(__name__)
def _conf_preprocess(value):
"""Preprocess alternative configuration formats."""
if isinstance(value, (str, list)):
if not isinstance(value, dict):
value = {CONF_ENTITIES: 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({
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)
# List of ON/OFF state tuples for groupable states
@ -88,6 +81,11 @@ def is_on(hass, entity_id):
return False
def reload(hass):
"""Reload the automation from config."""
hass.services.call(DOMAIN, SERVICE_RELOAD)
def expand_entity_ids(hass, entity_ids):
"""Return entity_ids with group entity ids replaced by their members."""
found_ids = []
@ -121,35 +119,59 @@ def expand_entity_ids(hass, entity_ids):
def get_entity_ids(hass, entity_id, domain_filter=None):
"""Get members of this group."""
entity_id = entity_id.lower()
group = hass.states.get(entity_id)
try:
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
if not group or ATTR_ENTITY_ID not in group.attributes:
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):
"""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():
name = conf.get(CONF_NAME, object_id)
entity_ids = conf.get(CONF_ENTITIES) or []
icon = conf.get(CONF_ICON)
view = conf.get(CONF_VIEW)
Group(hass, name, entity_ids, icon=icon, view=view,
object_id=object_id)
group = Group(hass, name, entity_ids, icon=icon, view=view,
object_id=object_id)
component.add_entities((group,))
return True
@ -242,17 +264,21 @@ class Group(Entity):
def stop(self):
"""Unregister the group from Home Assistant."""
self.hass.states.remove(self.entity_id)
if self._unsub_state_changed:
self._unsub_state_changed()
self._unsub_state_changed = None
self.remove()
def update(self):
"""Query all members and determine current group state."""
self._state = STATE_UNKNOWN
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):
"""Respond to a member state changing."""
self._update_group_state(new_state)

View File

@ -39,6 +39,11 @@ foursquare:
description: Vertical accuracy of the user's location, in meters.
example: 1
group:
reload:
description: "Reload group configuration."
fields:
persistent_notification:
create:
description: Show a notification in the frontend

View File

@ -1,11 +1,14 @@
"""Helpers for components that manage entities."""
from threading import Lock
from homeassistant.bootstrap import prepare_setup_platform
from homeassistant.components import group
from homeassistant import config as conf_util
from homeassistant.bootstrap import (prepare_setup_platform,
prepare_setup_component)
from homeassistant.const import (
ATTR_ENTITY_ID, CONF_SCAN_INTERVAL, CONF_ENTITY_NAMESPACE,
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.entity import generate_entity_id
from homeassistant.helpers.event import track_utc_time_change
@ -135,6 +138,7 @@ class EntityComponent(object):
def update_group(self):
"""Set up and/or update component group."""
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,
user_defined=False)
@ -157,6 +161,23 @@ class EntityComponent(object):
self.group.stop()
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):
"""Keep track of entities for a single platform."""

View File

@ -6,11 +6,11 @@ import logging
import jinja2
from jinja2.sandbox import ImmutableSandboxedEnvironment
from homeassistant.components import group
from homeassistant.const import STATE_UNKNOWN, ATTR_LATITUDE, ATTR_LONGITUDE
from homeassistant.core import State
from homeassistant.exceptions import TemplateError
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
_LOGGER = logging.getLogger(__name__)
@ -169,6 +169,8 @@ class LocationMethods(object):
else:
gr_entity_id = str(entities)
group = get_component('group')
states = [self._hass.states.get(entity_id) for 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.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.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.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.pool.block_till_done()

View File

@ -1,6 +1,7 @@
"""The tests for the Group components."""
# pylint: disable=protected-access,too-many-public-methods
import unittest
from unittest.mock import patch
from homeassistant.bootstrap import _setup_component
from homeassistant.const import (
@ -308,3 +309,33 @@ class TestComponentsGroup(unittest.TestCase):
self.assertEqual(STATE_NOT_HOME,
self.hass.states.get(
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