diff --git a/homeassistant/config.py b/homeassistant/config.py index 6c89c84b37b478d6de1da024d6b5cf7c5d8feb7f..f4cb1e5248b3273cf1690ee5281bf534ab255d79 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -13,9 +13,9 @@ import voluptuous as vol from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_PACKAGES, CONF_UNIT_SYSTEM, - CONF_TIME_ZONE, CONF_CUSTOMIZE, CONF_ELEVATION, CONF_UNIT_SYSTEM_METRIC, + CONF_TIME_ZONE, CONF_ELEVATION, CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT, TEMP_CELSIUS, - __version__) + __version__, CONF_CUSTOMIZE, CONF_CUSTOMIZE_DOMAIN, CONF_CUSTOMIZE_GLOB) from homeassistant.core import DOMAIN as CONF_CORE from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import get_component @@ -23,13 +23,14 @@ from homeassistant.util.yaml import load_yaml import homeassistant.helpers.config_validation as cv from homeassistant.util import dt as date_util, location as loc_util from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM -from homeassistant.helpers import customize +from homeassistant.helpers.entity_values import EntityValues _LOGGER = logging.getLogger(__name__) YAML_CONFIG_FILE = 'configuration.yaml' VERSION_FILE = '.HA_VERSION' CONFIG_DIR_NAME = '.homeassistant' +DATA_CUSTOMIZE = 'hass_customize' DEFAULT_CORE_CONFIG = ( # Tuples (attribute, default, auto detect property, description) @@ -96,7 +97,16 @@ PACKAGES_CONFIG_SCHEMA = vol.Schema({ {cv.slug: vol.Any(dict, list)}) # Only slugs for component names }) -CORE_CONFIG_SCHEMA = vol.Schema({ +CUSTOMIZE_CONFIG_SCHEMA = vol.Schema({ + vol.Optional(CONF_CUSTOMIZE, default={}): + vol.Schema({cv.entity_id: dict}), + vol.Optional(CONF_CUSTOMIZE_DOMAIN, default={}): + vol.Schema({cv.string: dict}), + vol.Optional(CONF_CUSTOMIZE_GLOB, default={}): + vol.Schema({cv.string: dict}), +}) + +CORE_CONFIG_SCHEMA = CUSTOMIZE_CONFIG_SCHEMA.extend({ CONF_NAME: vol.Coerce(str), CONF_LATITUDE: cv.latitude, CONF_LONGITUDE: cv.longitude, @@ -104,7 +114,6 @@ CORE_CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_TEMPERATURE_UNIT): cv.temperature_unit, CONF_UNIT_SYSTEM: cv.unit_system, CONF_TIME_ZONE: cv.time_zone, - vol.Optional(CONF_CUSTOMIZE, default=[]): customize.CUSTOMIZE_SCHEMA, vol.Optional(CONF_PACKAGES, default={}): PACKAGES_CONFIG_SCHEMA, }) @@ -289,9 +298,29 @@ def async_process_ha_core_config(hass, config): if CONF_TIME_ZONE in config: set_time_zone(config.get(CONF_TIME_ZONE)) - merged_customize = merge_packages_customize( - config[CONF_CUSTOMIZE], config[CONF_PACKAGES]) - customize.set_customize(hass, CONF_CORE, merged_customize) + # Customize + cust_exact = dict(config[CONF_CUSTOMIZE]) + cust_domain = dict(config[CONF_CUSTOMIZE_DOMAIN]) + cust_glob = dict(config[CONF_CUSTOMIZE_GLOB]) + + for name, pkg in config[CONF_PACKAGES].items(): + pkg_cust = pkg.get(CONF_CORE) + + if pkg_cust is None: + continue + + try: + pkg_cust = CUSTOMIZE_CONFIG_SCHEMA(pkg_cust) + except vol.Invalid: + _LOGGER.warning('Package %s contains invalid customize', name) + continue + + cust_exact.update(pkg_cust[CONF_CUSTOMIZE]) + cust_domain.update(pkg_cust[CONF_CUSTOMIZE_DOMAIN]) + cust_glob.update(pkg_cust[CONF_CUSTOMIZE_GLOB]) + + hass.data[DATA_CUSTOMIZE] = \ + EntityValues(cust_exact, cust_domain, cust_glob) if CONF_UNIT_SYSTEM in config: if config[CONF_UNIT_SYSTEM] == CONF_UNIT_SYSTEM_IMPERIAL: @@ -446,20 +475,6 @@ def merge_packages_config(config, packages): return config -def merge_packages_customize(core_customize, packages): - """Merge customize from packages.""" - schema = vol.Schema({ - vol.Optional(CONF_CORE): vol.Schema({ - CONF_CUSTOMIZE: customize.CUSTOMIZE_SCHEMA}), - }, extra=vol.ALLOW_EXTRA) - - cust = list(core_customize) - for pkg in packages.values(): - conf = schema(pkg) - cust.extend(conf.get(CONF_CORE, {}).get(CONF_CUSTOMIZE, [])) - return cust - - @asyncio.coroutine def async_check_ha_config_file(hass): """Check if HA config file valid. diff --git a/homeassistant/const.py b/homeassistant/const.py index f5f86321b1e5cf5850991e35153958161fbd5163..3de056753e6bdb87ee2ad12ad43fa955d17e9158 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -77,6 +77,8 @@ CONF_COMMAND_STOP = 'command_stop' CONF_CONDITION = 'condition' CONF_COVERS = 'covers' CONF_CUSTOMIZE = 'customize' +CONF_CUSTOMIZE_DOMAIN = 'customize_domain' +CONF_CUSTOMIZE_GLOB = 'customize_glob' CONF_DEVICE = 'device' CONF_DEVICE_CLASS = 'device_class' CONF_DEVICES = 'devices' diff --git a/homeassistant/helpers/customize.py b/homeassistant/helpers/customize.py deleted file mode 100644 index e9cd7c0269ae7f2a2c68b5b2948c2e583c26e097..0000000000000000000000000000000000000000 --- a/homeassistant/helpers/customize.py +++ /dev/null @@ -1,107 +0,0 @@ -"""A helper module for customization.""" -import collections -from typing import Any, Dict, List -import fnmatch -import voluptuous as vol - -from homeassistant.const import CONF_ENTITY_ID -from homeassistant.core import HomeAssistant, split_entity_id -import homeassistant.helpers.config_validation as cv - -_OVERWRITE_KEY_FORMAT = '{}.overwrite' -_OVERWRITE_CACHE_KEY_FORMAT = '{}.overwrite_cache' - -_CUSTOMIZE_SCHEMA_ENTRY = vol.Schema({ - vol.Required(CONF_ENTITY_ID): vol.All( - cv.ensure_list_csv, vol.Length(min=1), [vol.Schema(str)], [vol.Lower]) -}, extra=vol.ALLOW_EXTRA) - - -def _convert_old_config(inp: Any) -> List: - if not isinstance(inp, dict): - return cv.ensure_list(inp) - if CONF_ENTITY_ID in inp: - return [inp] # sigle entry - res = [] - - inp = vol.Schema({cv.match_all: dict})(inp) - for key, val in inp.items(): - val = dict(val) - val[CONF_ENTITY_ID] = key - res.append(val) - return res - - -CUSTOMIZE_SCHEMA = vol.All(_convert_old_config, [_CUSTOMIZE_SCHEMA_ENTRY]) - - -def set_customize( - hass: HomeAssistant, domain: str, customize: List[Dict]) -> None: - """Overwrite all current customize settings. - - Async friendly. - """ - hass.data[_OVERWRITE_KEY_FORMAT.format(domain)] = customize - hass.data[_OVERWRITE_CACHE_KEY_FORMAT.format(domain)] = {} - - -def get_overrides(hass: HomeAssistant, domain: str, entity_id: str) -> Dict: - """Return a dictionary of overrides related to entity_id. - - Whole-domain overrides are of lowest priorities, - then glob on entity ID, and finally exact entity_id - matches are of highest priority. - - The lookups are cached. - """ - cache_key = _OVERWRITE_CACHE_KEY_FORMAT.format(domain) - if cache_key in hass.data and entity_id in hass.data[cache_key]: - return hass.data[cache_key][entity_id] - overwrite_key = _OVERWRITE_KEY_FORMAT.format(domain) - if overwrite_key not in hass.data: - return {} - domain_result = {} # type: Dict[str, Any] - glob_result = {} # type: Dict[str, Any] - exact_result = {} # type: Dict[str, Any] - domain = split_entity_id(entity_id)[0] - - def clean_entry(entry: Dict) -> Dict: - """Clean up entity-matching keys.""" - entry.pop(CONF_ENTITY_ID, None) - return entry - - def deep_update(target: Dict, source: Dict) -> None: - """Deep update a dictionary.""" - for key, value in source.items(): - if isinstance(value, collections.Mapping): - updated_value = target.get(key, {}) - # If the new value is map, but the old value is not - - # overwrite the old value. - if not isinstance(updated_value, collections.Mapping): - updated_value = {} - deep_update(updated_value, value) - target[key] = updated_value - else: - target[key] = source[key] - - for rule in hass.data[overwrite_key]: - if CONF_ENTITY_ID in rule: - entities = rule[CONF_ENTITY_ID] - if domain in entities: - deep_update(domain_result, rule) - if entity_id in entities: - deep_update(exact_result, rule) - for entity_id_glob in entities: - if entity_id_glob == entity_id: - continue - if fnmatch.fnmatchcase(entity_id, entity_id_glob): - deep_update(glob_result, rule) - break - result = {} - deep_update(result, clean_entry(domain_result)) - deep_update(result, clean_entry(glob_result)) - deep_update(result, clean_entry(exact_result)) - if cache_key not in hass.data: - hass.data[cache_key] = {} - hass.data[cache_key][entity_id] = result - return result diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index e66f49cc6dcede8ef7c5e1323945063eb4b92b2b..0705f60a9b69d923e8c27e9e1af044a99c1a82d5 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -11,12 +11,12 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, DEVICE_DEFAULT_NAME, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_ENTITY_PICTURE, ATTR_SUPPORTED_FEATURES, ATTR_DEVICE_CLASS) -from homeassistant.core import HomeAssistant, DOMAIN as CORE_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.config import DATA_CUSTOMIZE from homeassistant.exceptions import NoEntitySpecifiedError from homeassistant.util import ensure_unique_string, slugify from homeassistant.util.async import ( run_coroutine_threadsafe, run_callback_threadsafe) -from homeassistant.helpers.customize import get_overrides _LOGGER = logging.getLogger(__name__) @@ -209,8 +209,6 @@ class Entity(object): # pylint: disable=no-member yield from self.async_update() else: - # PS: Run this in our own thread pool once we have - # future support? yield from self.hass.loop.run_in_executor(None, self.update) start = timer() @@ -253,7 +251,8 @@ class Entity(object): end - start) # Overwrite properties that have been set in the config file. - attr.update(get_overrides(self.hass, CORE_DOMAIN, self.entity_id)) + if DATA_CUSTOMIZE in self.hass.data: + attr.update(self.hass.data[DATA_CUSTOMIZE].get(self.entity_id)) # Remove hidden property if false so it won't show up. if not attr.get(ATTR_HIDDEN, True): diff --git a/homeassistant/helpers/entity_values.py b/homeassistant/helpers/entity_values.py new file mode 100644 index 0000000000000000000000000000000000000000..19980394d26d72792541876a6d7d38caad1d4fa6 --- /dev/null +++ b/homeassistant/helpers/entity_values.py @@ -0,0 +1,46 @@ +"""A class to hold entity values.""" +from collections import OrderedDict +import fnmatch +import re + +from homeassistant.core import split_entity_id + + +class EntityValues(object): + """Class to store entity id based values.""" + + def __init__(self, exact=None, domain=None, glob=None): + """Initialize an EntityConfigDict.""" + self._cache = {} + self._exact = exact + self._domain = domain + + if glob is None: + compiled = None + else: + compiled = OrderedDict() + for key, value in glob.items(): + compiled[re.compile(fnmatch.translate(key))] = value + + self._glob = compiled + + def get(self, entity_id): + """Get config for an entity id.""" + if entity_id in self._cache: + return self._cache[entity_id] + + domain, _ = split_entity_id(entity_id) + result = self._cache[entity_id] = {} + + if self._domain is not None and domain in self._domain: + result.update(self._domain[domain]) + + if self._glob is not None: + for pattern, values in self._glob.items(): + if pattern.match(entity_id): + result.update(values) + + if self._exact is not None and entity_id in self._exact: + result.update(self._exact[entity_id]) + + return result diff --git a/tests/helpers/test_customize.py b/tests/helpers/test_customize.py deleted file mode 100644 index 0fb1b6ab14c4a3a9a423eb310ab73982ef01f0a1..0000000000000000000000000000000000000000 --- a/tests/helpers/test_customize.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Test the customize helper.""" -import homeassistant.helpers.customize as customize -from voluptuous import MultipleInvalid -import pytest - - -class MockHass(object): - """Mock object for HassAssistant.""" - - data = {} - - -class TestHelpersCustomize(object): - """Test homeassistant.helpers.customize module.""" - - def setup_method(self, method): - """Setup things to be run when tests are started.""" - self.entity_id = 'test.test' - self.hass = MockHass() - - def _get_overrides(self, overrides): - test_domain = 'test.domain' - customize.set_customize(self.hass, test_domain, overrides) - return customize.get_overrides(self.hass, test_domain, self.entity_id) - - def test_override_single_value(self): - """Test entity customization through configuration.""" - result = self._get_overrides([ - {'entity_id': [self.entity_id], 'key': 'value'}]) - - assert result == {'key': 'value'} - - def test_override_multiple_values(self): - """Test entity customization through configuration.""" - result = self._get_overrides([ - {'entity_id': [self.entity_id], 'key1': 'value1'}, - {'entity_id': [self.entity_id], 'key2': 'value2'}]) - - assert result == {'key1': 'value1', 'key2': 'value2'} - - def test_override_same_value(self): - """Test entity customization through configuration.""" - result = self._get_overrides([ - {'entity_id': [self.entity_id], 'key': 'value1'}, - {'entity_id': [self.entity_id], 'key': 'value2'}]) - - assert result == {'key': 'value2'} - - def test_override_by_domain(self): - """Test entity customization through configuration.""" - result = self._get_overrides([ - {'entity_id': ['test'], 'key': 'value'}]) - - assert result == {'key': 'value'} - - def test_override_by_glob(self): - """Test entity customization through configuration.""" - result = self._get_overrides([ - {'entity_id': ['test.?e*'], 'key': 'value'}]) - - assert result == {'key': 'value'} - - def test_override_exact_over_glob_over_domain(self): - """Test entity customization through configuration.""" - result = self._get_overrides([ - {'entity_id': ['test.test'], 'key1': 'valueExact'}, - {'entity_id': ['test.tes?'], - 'key1': 'valueGlob', - 'key2': 'valueGlob'}, - {'entity_id': ['test'], - 'key1': 'valueDomain', - 'key2': 'valueDomain', - 'key3': 'valueDomain'}]) - - assert result == { - 'key1': 'valueExact', - 'key2': 'valueGlob', - 'key3': 'valueDomain'} - - def test_override_deep_dict(self): - """Test we can deep-overwrite a dict.""" - result = self._get_overrides( - [{'entity_id': [self.entity_id], - 'test': {'key1': 'value1', 'key2': 'value2'}}, - {'entity_id': [self.entity_id], - 'test': {'key3': 'value3', 'key2': 'value22'}}]) - assert result['test'] == { - 'key1': 'value1', - 'key2': 'value22', - 'key3': 'value3'} - - def test_schema_bad_schema(self): - """Test bad customize schemas.""" - for value in ( - {'test.test': 10}, - {'test.test': ['hello']}, - {'entity_id': {'a': 'b'}}, - {'entity_id': 10}, - [{'test.test': 'value'}], - ): - with pytest.raises( - MultipleInvalid, - message="{} should have raised MultipleInvalid".format( - value)): - customize.CUSTOMIZE_SCHEMA(value) - - def test_get_customize_schema_allow_extra(self): - """Test schema with ALLOW_EXTRA.""" - for value in ( - {'test.test': {'hidden': True}}, - {'test.test': {'key': ['value1', 'value2']}}, - [{'entity_id': 'id1', 'key': 'value'}], - ): - customize.CUSTOMIZE_SCHEMA(value) - - def test_get_customize_schema_csv(self): - """Test schema with comma separated entity IDs.""" - assert [{'entity_id': ['id1', 'id2', 'id3']}] == \ - customize.CUSTOMIZE_SCHEMA([{'entity_id': 'id1,ID2 , id3'}]) diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index c114478319fb7172607c19481bd94f1fbd98728d..965afde83092bb5e75cf3ec39965790bc04ec4d8 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -7,8 +7,9 @@ from unittest.mock import patch import pytest import homeassistant.helpers.entity as entity -from homeassistant.helpers.customize import set_customize from homeassistant.const import ATTR_HIDDEN, ATTR_DEVICE_CLASS +from homeassistant.config import DATA_CUSTOMIZE +from homeassistant.helpers.entity_values import EntityValues from tests.common import get_test_home_assistant @@ -89,10 +90,8 @@ class TestHelpersEntity(object): def test_overwriting_hidden_property_to_true(self): """Test we can overwrite hidden property to True.""" - set_customize( - self.hass, - entity.CORE_DOMAIN, - [{'entity_id': [self.entity.entity_id], ATTR_HIDDEN: True}]) + self.hass.data[DATA_CUSTOMIZE] = EntityValues({ + self.entity.entity_id: {ATTR_HIDDEN: True}}) self.entity.update_ha_state() state = self.hass.states.get(self.entity.entity_id) diff --git a/tests/helpers/test_entity_values.py b/tests/helpers/test_entity_values.py new file mode 100644 index 0000000000000000000000000000000000000000..332591165b5072cfff522f36100b62b1d443db9b --- /dev/null +++ b/tests/helpers/test_entity_values.py @@ -0,0 +1,68 @@ +"""Test the entity values helper.""" +from collections import OrderedDict +from homeassistant.helpers.entity_values import EntityValues as EV + +ent = 'test.test' + + +def test_override_single_value(): + """Test values with exact match.""" + store = EV({ent: {'key': 'value'}}) + assert store.get(ent) == {'key': 'value'} + assert len(store._cache) == 1 + assert store.get(ent) == {'key': 'value'} + assert len(store._cache) == 1 + + +def test_override_by_domain(): + """Test values with domain match.""" + store = EV(domain={'test': {'key': 'value'}}) + assert store.get(ent) == {'key': 'value'} + + +def test_override_by_glob(): + """Test values with glob match.""" + store = EV(glob={'test.?e*': {'key': 'value'}}) + assert store.get(ent) == {'key': 'value'} + + +def test_glob_overrules_domain(): + """Test domain overrules glob match.""" + store = EV( + domain={'test': {'key': 'domain'}}, + glob={'test.?e*': {'key': 'glob'}}) + assert store.get(ent) == {'key': 'glob'} + + +def test_exact_overrules_domain(): + """Test exact overrules domain match.""" + store = EV( + exact={'test.test': {'key': 'exact'}}, + domain={'test': {'key': 'domain'}}, + glob={'test.?e*': {'key': 'glob'}}) + assert store.get(ent) == {'key': 'exact'} + + +def test_merging_values(): + """Test merging glob, domain and exact configs.""" + store = EV( + exact={'test.test': {'exact_key': 'exact'}}, + domain={'test': {'domain_key': 'domain'}}, + glob={'test.?e*': {'glob_key': 'glob'}}) + assert store.get(ent) == { + 'exact_key': 'exact', + 'domain_key': 'domain', + 'glob_key': 'glob', + } + + +def test_glob_order(): + """Test merging glob, domain and exact configs.""" + glob = OrderedDict() + glob['test.*est'] = {"value": "first"} + glob['test.*'] = {"value": "second"} + + store = EV(glob=glob) + assert store.get(ent) == { + 'value': 'second' + } diff --git a/tests/test_config.py b/tests/test_config.py index a796cc615efd7989a71c2502b7700029b7a72272..fefc39544e3db5c433060080b0889f0f22319b85 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -547,11 +547,5 @@ def test_merge_customize(hass): } yield from config_util.async_process_ha_core_config(hass, core_config) - entity = Entity() - entity.entity_id = 'b.b' - entity.hass = hass - yield from entity.async_update_ha_state() - - state = hass.states.get('b.b') - assert state is not None - assert state.attributes['friendly_name'] == 'BB' + assert hass.data[config_util.DATA_CUSTOMIZE].get('b.b') == \ + {'friendly_name': 'BB'}