diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 3e8ed6ad77f..5e291e90717 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -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( diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 40715bca502..863d94033a8 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -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, diff --git a/homeassistant/components/group.py b/homeassistant/components/group.py index 4444b97ebe2..c4cd177925d 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group.py @@ -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) diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index ac6d9829fc5..4f79a2ee627 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -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 diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index e853d20df89..3146d703d19 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -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.""" diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index fab081cc5c5..056a4e60183 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -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])] diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index f244bb3a23b..3d69cca2d32 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -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() diff --git a/tests/components/test_group.py b/tests/components/test_group.py index d815489ae21..e82190a3f29 100644 --- a/tests/components/test_group.py +++ b/tests/components/test_group.py @@ -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