From afe9fc221ec2c0987bceb28ce84612485a85583d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 20 May 2019 20:02:36 +0200 Subject: [PATCH] Fire event when core config is updated (#23922) * Fire event when core config is updated --- homeassistant/config.py | 74 +++----------------------- homeassistant/const.py | 18 ++++--- homeassistant/core.py | 112 ++++++++++++++++++++++++++++++++++++---- tests/common.py | 1 - tests/test_config.py | 41 +++++++++++---- tests/test_core.py | 31 ++++++++++- 6 files changed, 180 insertions(+), 97 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index b084ed358f3..ec83818d903 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -23,14 +23,16 @@ from homeassistant.const import ( __version__, CONF_CUSTOMIZE, CONF_CUSTOMIZE_DOMAIN, CONF_CUSTOMIZE_GLOB, CONF_WHITELIST_EXTERNAL_DIRS, CONF_AUTH_PROVIDERS, CONF_AUTH_MFA_MODULES, CONF_TYPE, CONF_ID) -from homeassistant.core import callback, DOMAIN as CONF_CORE, HomeAssistant +from homeassistant.core import ( + DOMAIN as CONF_CORE, SOURCE_DISCOVERED, SOURCE_YAML, HomeAssistant, + callback) from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import ( Integration, async_get_integration, IntegrationNotFound ) from homeassistant.util.yaml import load_yaml, SECRET_YAML import homeassistant.helpers.config_validation as cv -from homeassistant.util import dt as date_util, location as loc_util +from homeassistant.util import location as loc_util from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM from homeassistant.helpers.entity_values import EntityValues from homeassistant.helpers import config_per_platform, extract_domain_configs @@ -50,13 +52,6 @@ FILE_MIGRATION = ( ('ios.conf', '.ios.conf'), ) -CORE_STORAGE_KEY = 'homeassistant.core_config' -CORE_STORAGE_VERSION = 1 - -SOURCE_DISCOVERED = 'discovered' -SOURCE_STORAGE = 'storage' -SOURCE_YAML = 'yaml' - DEFAULT_CORE_CONFIG = ( # Tuples (attribute, default, auto detect property, description) (CONF_NAME, 'Home', None, 'Name of the location where Home Assistant is ' @@ -478,42 +473,6 @@ def _format_config_error(ex: vol.Invalid, domain: str, config: Dict) -> str: return message -def _set_time_zone(hass: HomeAssistant, time_zone_str: Optional[str]) -> None: - """Help to set the time zone.""" - if time_zone_str is None: - return - - time_zone = date_util.get_time_zone(time_zone_str) - - if time_zone: - hass.config.time_zone = time_zone - date_util.set_default_time_zone(time_zone) - else: - _LOGGER.error("Received invalid time zone %s", time_zone_str) - - -async def async_load_ha_core_config(hass: HomeAssistant) -> None: - """Store [homeassistant] core config.""" - store = hass.helpers.storage.Store(CORE_STORAGE_VERSION, CORE_STORAGE_KEY, - private=True) - data = await store.async_load() - if not data: - return - - hac = hass.config - hac.config_source = SOURCE_STORAGE - hac.latitude = data['latitude'] - hac.longitude = data['longitude'] - hac.elevation = data['elevation'] - unit_system = data['unit_system'] - if unit_system == CONF_UNIT_SYSTEM_IMPERIAL: - hac.units = IMPERIAL_SYSTEM - else: - hac.units = METRIC_SYSTEM - hac.location_name = data['location_name'] - _set_time_zone(hass, data['time_zone']) - - async def async_process_ha_core_config( hass: HomeAssistant, config: Dict, api_password: Optional[str] = None, @@ -552,7 +511,7 @@ async def async_process_ha_core_config( auth_conf, mfa_conf)) - await async_load_ha_core_config(hass) + await hass.config.async_load() hac = hass.config @@ -568,7 +527,8 @@ async def async_process_ha_core_config( if key in config: setattr(hac, attr, config[key]) - _set_time_zone(hass, config.get(CONF_TIME_ZONE)) + if CONF_TIME_ZONE in config: + hac.set_time_zone(config[CONF_TIME_ZONE]) # Init whitelist external dir hac.whitelist_external_dirs = {hass.config.path('www')} @@ -649,7 +609,7 @@ async def async_process_ha_core_config( discovered.append(('name', info.city)) if hac.time_zone is None: - _set_time_zone(hass, info.time_zone) + hac.set_time_zone(info.time_zone) discovered.append(('time_zone', info.time_zone)) if hac.elevation is None and hac.latitude is not None and \ @@ -666,24 +626,6 @@ async def async_process_ha_core_config( ", ".join('{}: {}'.format(key, val) for key, val in discovered)) -async def async_store_ha_core_config(hass: HomeAssistant) -> None: - """Store [homeassistant] core config.""" - config = hass.config.as_dict() - - data = { - 'latitude': config['latitude'], - 'longitude': config['longitude'], - 'elevation': config['elevation'], - 'unit_system': hass.config.units.name, - 'location_name': config['location_name'], - 'time_zone': config['time_zone'], - } - - store = hass.helpers.storage.Store(CORE_STORAGE_VERSION, CORE_STORAGE_KEY, - private=True) - await store.async_save(data) - - def _log_pkg_error( package: str, component: str, config: Dict, message: str) -> None: """Log an error while merging packages.""" diff --git a/homeassistant/const.py b/homeassistant/const.py index 8d21c9d191e..1dcea3e2daf 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -160,21 +160,23 @@ CONF_XY = 'xy' CONF_ZONE = 'zone' # #### EVENTS #### +EVENT_AUTOMATION_TRIGGERED = 'automation_triggered' +EVENT_CALL_SERVICE = 'call_service' +EVENT_COMPONENT_LOADED = 'component_loaded' +EVENT_CORE_CONFIG_UPDATE = 'core_config_updated' +EVENT_HOMEASSISTANT_CLOSE = 'homeassistant_close' EVENT_HOMEASSISTANT_START = 'homeassistant_start' EVENT_HOMEASSISTANT_STOP = 'homeassistant_stop' -EVENT_HOMEASSISTANT_CLOSE = 'homeassistant_close' -EVENT_STATE_CHANGED = 'state_changed' -EVENT_TIME_CHANGED = 'time_changed' -EVENT_CALL_SERVICE = 'call_service' +EVENT_LOGBOOK_ENTRY = 'logbook_entry' EVENT_PLATFORM_DISCOVERED = 'platform_discovered' -EVENT_COMPONENT_LOADED = 'component_loaded' +EVENT_SCRIPT_STARTED = 'script_started' EVENT_SERVICE_REGISTERED = 'service_registered' EVENT_SERVICE_REMOVED = 'service_removed' -EVENT_LOGBOOK_ENTRY = 'logbook_entry' +EVENT_STATE_CHANGED = 'state_changed' EVENT_THEMES_UPDATED = 'themes_updated' EVENT_TIMER_OUT_OF_SYNC = 'timer_out_of_sync' -EVENT_AUTOMATION_TRIGGERED = 'automation_triggered' -EVENT_SCRIPT_STARTED = 'script_started' +EVENT_TIME_CHANGED = 'time_changed' + # #### DEVICE CLASSES #### DEVICE_CLASS_BATTERY = 'battery' diff --git a/homeassistant/core.py b/homeassistant/core.py index a02c1b687ab..4dd84cc1a46 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -27,12 +27,12 @@ import attr import voluptuous as vol from homeassistant.const import ( - ATTR_DOMAIN, ATTR_FRIENDLY_NAME, ATTR_NOW, ATTR_SERVICE, - ATTR_SERVICE_DATA, ATTR_SECONDS, EVENT_CALL_SERVICE, - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, - EVENT_HOMEASSISTANT_CLOSE, EVENT_SERVICE_REMOVED, - EVENT_SERVICE_REGISTERED, EVENT_STATE_CHANGED, - EVENT_TIME_CHANGED, EVENT_TIMER_OUT_OF_SYNC, MATCH_ALL, __version__) + ATTR_DOMAIN, ATTR_FRIENDLY_NAME, ATTR_NOW, ATTR_SERVICE, ATTR_SERVICE_DATA, + ATTR_SECONDS, CONF_UNIT_SYSTEM_IMPERIAL, EVENT_CALL_SERVICE, + EVENT_CORE_CONFIG_UPDATE, EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_CLOSE, EVENT_SERVICE_REMOVED, + EVENT_SERVICE_REGISTERED, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, + EVENT_TIMER_OUT_OF_SYNC, MATCH_ALL, __version__) from homeassistant import loader from homeassistant.exceptions import ( HomeAssistantError, InvalidEntityFormatError, InvalidStateError, @@ -43,7 +43,8 @@ from homeassistant.util.async_ import ( from homeassistant import util import homeassistant.util.dt as dt_util from homeassistant.util import location, slugify -from homeassistant.util.unit_system import UnitSystem, METRIC_SYSTEM # NOQA +from homeassistant.util.unit_system import ( # NOQA + UnitSystem, IMPERIAL_SYSTEM, METRIC_SYSTEM) # Typing imports that create a circular dependency # pylint: disable=using-constant-test @@ -56,11 +57,19 @@ CALLABLE_T = TypeVar('CALLABLE_T', bound=Callable) CALLBACK_TYPE = Callable[[], None] # pylint: enable=invalid-name +CORE_STORAGE_KEY = 'homeassistant.core_config' +CORE_STORAGE_VERSION = 1 + DOMAIN = 'homeassistant' # How long we wait for the result of a service call SERVICE_CALL_LIMIT = 10 # seconds +# Source of core configuration +SOURCE_DISCOVERED = 'discovered' +SOURCE_STORAGE = 'storage' +SOURCE_YAML = 'yaml' + # How long to wait till things that run on startup have to finish. TIMEOUT_EVENT_START = 15 @@ -144,7 +153,7 @@ class HomeAssistant: self.bus = EventBus(self) self.services = ServiceRegistry(self) self.states = StateMachine(self.bus, self.loop) - self.config = Config() # type: Config + self.config = Config(self) # type: Config self.components = loader.Components(self) self.helpers = loader.Helpers(self) # This is a dictionary that any component can store any data on. @@ -1168,8 +1177,10 @@ class ServiceRegistry: class Config: """Configuration settings for Home Assistant.""" - def __init__(self) -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize a new config object.""" + self.hass = hass + self.latitude = None # type: Optional[float] self.longitude = None # type: Optional[float] self.elevation = None # type: Optional[int] @@ -1235,7 +1246,7 @@ class Config: return False def as_dict(self) -> Dict: - """Create a dictionary representation of this dict. + """Create a dictionary representation of the configuration. Async friendly. """ @@ -1257,6 +1268,87 @@ class Config: 'config_source': self.config_source } + def set_time_zone(self, time_zone_str: str) -> None: + """Help to set the time zone.""" + time_zone = dt_util.get_time_zone(time_zone_str) + + if time_zone: + self.time_zone = time_zone + dt_util.set_default_time_zone(time_zone) + else: + raise ValueError( + "Received invalid time zone {}".format(time_zone_str)) + + @callback + def _update(self, *, + source: str, + latitude: Optional[float] = None, + longitude: Optional[float] = None, + elevation: Optional[int] = None, + unit_system: Optional[str] = None, + location_name: Optional[str] = None, + time_zone: Optional[str] = None) -> None: + """Update the configuration from a dictionary. + + Async friendly. + """ + self.config_source = source + if latitude is not None: + self.latitude = latitude + if longitude is not None: + self.longitude = longitude + if elevation is not None: + self.elevation = elevation + if unit_system is not None: + if unit_system == CONF_UNIT_SYSTEM_IMPERIAL: + self.units = IMPERIAL_SYSTEM + else: + self.units = METRIC_SYSTEM + if location_name is not None: + self.location_name = location_name + if time_zone is not None: + self.set_time_zone(time_zone) + + async def update(self, **kwargs: Any) -> None: + """Update the configuration from a dictionary. + + Async friendly. + """ + self._update(source=SOURCE_STORAGE, **kwargs) + await self.async_store() + self.hass.bus.async_fire( + EVENT_CORE_CONFIG_UPDATE, kwargs + ) + + async def async_load(self) -> None: + """Load [homeassistant] core config.""" + store = self.hass.helpers.storage.Store( + CORE_STORAGE_VERSION, CORE_STORAGE_KEY, private=True) + data = await store.async_load() + if not data: + return + + self._update(source=SOURCE_STORAGE, **data) + + async def async_store(self) -> None: + """Store [homeassistant] core config.""" + time_zone = dt_util.UTC.zone + if self.time_zone and getattr(self.time_zone, 'zone'): + time_zone = getattr(self.time_zone, 'zone') + + data = { + 'latitude': self.latitude, + 'longitude': self.longitude, + 'elevation': self.elevation, + 'unit_system': self.units.name, + 'location_name': self.location_name, + 'time_zone': time_zone, + } + + store = self.hass.helpers.storage.Store( + CORE_STORAGE_VERSION, CORE_STORAGE_KEY, private=True) + await store.async_save(data) + def _async_create_timer(hass: HomeAssistant) -> None: """Create a timer that will start on HOMEASSISTANT_START.""" diff --git a/tests/common.py b/tests/common.py index 572cd19a006..f7b3bc46bbd 100644 --- a/tests/common.py +++ b/tests/common.py @@ -122,7 +122,6 @@ def get_test_home_assistant(): async def async_test_home_assistant(loop): """Return a Home Assistant object pointing at test config dir.""" hass = ha.HomeAssistant(loop) - hass.config.async_load = Mock() store = auth_store.AuthStore(hass) hass.auth = auth.AuthManager(hass, store, {}, {}) ensure_auth_manager_loaded(hass.auth) diff --git a/tests/test_config.py b/tests/test_config.py index c081d97ed7c..42386cc1f4c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,7 @@ """Test config utils.""" # pylint: disable=protected-access import asyncio +import copy import os import unittest.mock as mock from collections import OrderedDict @@ -11,7 +12,8 @@ import pytest from voluptuous import MultipleInvalid, Invalid import yaml -from homeassistant.core import DOMAIN, HomeAssistantError, Config +from homeassistant.core import ( + DOMAIN, SOURCE_STORAGE, Config, HomeAssistantError) import homeassistant.config as config_util from homeassistant.loader import async_get_integration from homeassistant.const import ( @@ -439,7 +441,32 @@ async def test_loading_configuration_from_storage(hass, hass_storage): assert hass.config.time_zone.zone == 'Europe/Copenhagen' assert len(hass.config.whitelist_external_dirs) == 2 assert '/tmp' in hass.config.whitelist_external_dirs - assert hass.config.config_source == config_util.SOURCE_STORAGE + assert hass.config.config_source == SOURCE_STORAGE + + +async def test_updating_configuration(hass, hass_storage): + """Test updating configuration stores the new configuration.""" + core_data = { + 'data': { + 'elevation': 10, + 'latitude': 55, + 'location_name': 'Home', + 'longitude': 13, + 'time_zone': 'Europe/Copenhagen', + 'unit_system': 'metric' + }, + 'key': 'homeassistant.core_config', + 'version': 1 + } + hass_storage["homeassistant.core_config"] = dict(core_data) + await config_util.async_process_ha_core_config( + hass, {'whitelist_external_dirs': '/tmp'}) + await hass.config.update(latitude=50) + + new_core_data = copy.deepcopy(core_data) + new_core_data['data']['latitude'] = 50 + assert hass_storage["homeassistant.core_config"] == new_core_data + assert hass.config.latitude == 50 async def test_override_stored_configuration(hass, hass_storage): @@ -474,8 +501,6 @@ async def test_override_stored_configuration(hass, hass_storage): async def test_loading_configuration(hass): """Test loading core config onto hass object.""" - hass.config = mock.Mock() - await config_util.async_process_ha_core_config(hass, { 'latitude': 60, 'longitude': 50, @@ -499,8 +524,6 @@ async def test_loading_configuration(hass): async def test_loading_configuration_temperature_unit(hass): """Test backward compatibility when loading core config.""" - hass.config = mock.Mock() - await config_util.async_process_ha_core_config(hass, { 'latitude': 60, 'longitude': 50, @@ -521,8 +544,6 @@ async def test_loading_configuration_temperature_unit(hass): async def test_loading_configuration_from_packages(hass): """Test loading packages config onto hass object config.""" - hass.config = mock.Mock() - await config_util.async_process_ha_core_config(hass, { 'latitude': 39, 'longitude': -1, @@ -586,12 +607,12 @@ async def test_discovering_configuration_auto_detect_fails(mock_detect, mock_elevation, hass): """Test config remains unchanged if discovery fails.""" - hass.config = Config() + hass.config = Config(hass) hass.config.config_dir = "/test/config" await config_util.async_process_ha_core_config(hass, {}) - blankConfig = Config() + blankConfig = Config(hass) assert hass.config.latitude == blankConfig.latitude assert hass.config.longitude == blankConfig.longitude assert hass.config.elevation == blankConfig.elevation diff --git a/tests/test_core.py b/tests/test_core.py index afbbe3e33b2..101396dd05e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -23,7 +23,8 @@ from homeassistant.const import ( __version__, EVENT_STATE_CHANGED, ATTR_FRIENDLY_NAME, CONF_UNIT_SYSTEM, ATTR_NOW, EVENT_TIME_CHANGED, EVENT_TIMER_OUT_OF_SYNC, ATTR_SECONDS, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_CLOSE, - EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, EVENT_CALL_SERVICE) + EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, EVENT_CALL_SERVICE, + EVENT_CORE_CONFIG_UPDATE) from tests.common import get_test_home_assistant, async_mock_service @@ -871,7 +872,7 @@ class TestConfig(unittest.TestCase): # pylint: disable=invalid-name def setUp(self): """Set up things to be run when tests are started.""" - self.config = ha.Config() + self.config = ha.Config(None) assert self.config.config_dir is None def test_path_with_file(self): @@ -942,6 +943,32 @@ class TestConfig(unittest.TestCase): self.config.is_allowed_path(None) +async def test_event_on_update(hass, hass_storage): + """Test that event is fired on update.""" + events = [] + + @ha.callback + def callback(event): + events.append(event) + + hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, callback) + + assert hass.config.latitude != 12 + + await hass.config.update(latitude=12) + await hass.async_block_till_done() + + assert hass.config.latitude == 12 + assert len(events) == 1 + assert events[0].data == {'latitude': 12} + + +def test_bad_timezone_raises_value_error(hass): + """Test bad timezone raises ValueError.""" + with pytest.raises(ValueError): + hass.config.set_time_zone('not_a_timezone') + + @patch('homeassistant.core.monotonic') def test_create_timer(mock_monotonic, loop): """Test create timer."""