Simplify customize (#6007)

* Simplify customize

* Maintain glob order

* Have glob overrule domain
pull/6037/head
Paulus Schoutsen 2017-02-15 19:47:30 -08:00 committed by GitHub
parent eb9400de4c
commit 235d0057b1
9 changed files with 163 additions and 266 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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