From eceef82ffa3352dd6607247fb2b7f169c6974847 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen <balloob@gmail.com> Date: Mon, 5 Aug 2019 14:04:20 -0700 Subject: [PATCH] Add service to reload scenes from configuration.yaml (#25680) * Allow reloading scenes * Update requirements * address comments * fix typing * fix tests * Update homeassistant/components/homeassistant/scene.py Co-Authored-By: Martin Hjelmare <marhje52@kth.se> * Address comments --- .../components/homeassistant/scene.py | 68 +++++++++++++-- homeassistant/components/scene/__init__.py | 5 ++ homeassistant/helpers/entity_component.py | 9 +- homeassistant/helpers/entity_platform.py | 8 ++ homeassistant/package_constraints.txt | 1 + requirements_all.txt | 1 + setup.py | 86 +++++++++---------- tests/components/homeassistant/test_scene.py | 30 +++++++ tests/helpers/test_entity_component.py | 2 +- 9 files changed, 153 insertions(+), 57 deletions(-) create mode 100644 tests/components/homeassistant/test_scene.py diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py index de8a4dc88e7..66b04109640 100644 --- a/homeassistant/components/homeassistant/scene.py +++ b/homeassistant/components/homeassistant/scene.py @@ -1,5 +1,6 @@ """Allow users to set and activate scenes.""" from collections import namedtuple +import logging import voluptuous as vol @@ -11,12 +12,19 @@ from homeassistant.const import ( CONF_PLATFORM, STATE_OFF, STATE_ON, + SERVICE_RELOAD, +) +from homeassistant.core import State, DOMAIN +from homeassistant import config as conf_util +from homeassistant.exceptions import HomeAssistantError +from homeassistant.loader import async_get_integration +from homeassistant.helpers import ( + config_per_platform, + config_validation as cv, + entity_platform, ) -from homeassistant.core import State -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.state import HASS_DOMAIN, async_reproduce_state -from homeassistant.components.scene import STATES, Scene - +from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, STATES, Scene PLATFORM_SCHEMA = vol.Schema( { @@ -37,19 +45,63 @@ PLATFORM_SCHEMA = vol.Schema( ) SCENECONFIG = namedtuple("SceneConfig", [CONF_NAME, STATES]) +_LOGGER = logging.getLogger(__name__) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up home assistant scene entries.""" - scene_config = config.get(STATES) + _process_scenes_config(hass, async_add_entities, config) + + # This platform can be loaded multiple times. Only first time register the service. + if hass.services.has_service(SCENE_DOMAIN, SERVICE_RELOAD): + return + + # Store platform for later. + platform = entity_platform.current_platform.get() + + async def reload_config(call): + """Reload the scene config.""" + try: + conf = await conf_util.async_hass_config_yaml(hass) + except HomeAssistantError as err: + _LOGGER.error(err) + return + + integration = await async_get_integration(hass, SCENE_DOMAIN) + + conf = await conf_util.async_process_component_config(hass, conf, integration) + + if not conf or not platform: + return + + await platform.async_reset() + + # Extract only the config for the Home Assistant platform, ignore the rest. + for p_type, p_config in config_per_platform(conf, SCENE_DOMAIN): + if p_type != DOMAIN: + continue + + _process_scenes_config(hass, async_add_entities, p_config) + + hass.helpers.service.async_register_admin_service( + SCENE_DOMAIN, SERVICE_RELOAD, reload_config + ) + + +def _process_scenes_config(hass, async_add_entities, config): + """Process multiple scenes and add them.""" + scene_config = config[STATES] + + # Check empty list + if not scene_config: + return async_add_entities( - HomeAssistantScene(hass, _process_config(scene)) for scene in scene_config + HomeAssistantScene(hass, _process_scene_config(scene)) for scene in scene_config ) - return True -def _process_config(scene_config): +def _process_scene_config(scene_config): """Process passed in config into a format to work with. Async friendly. diff --git a/homeassistant/components/scene/__init__.py b/homeassistant/components/scene/__init__.py index 0d00c2c5ea2..5ddb1116d8f 100644 --- a/homeassistant/components/scene/__init__.py +++ b/homeassistant/components/scene/__init__.py @@ -5,6 +5,7 @@ import logging import voluptuous as vol +from homeassistant.core import DOMAIN as HA_DOMAIN from homeassistant.const import CONF_PLATFORM, SERVICE_TURN_ON from homeassistant.helpers.config_validation import ENTITY_SERVICE_SCHEMA from homeassistant.helpers.entity import Entity @@ -60,6 +61,10 @@ async def async_setup(hass, config): component = hass.data[DOMAIN] = EntityComponent(logger, DOMAIN, hass) await component.async_setup(config) + # Ensure Home Assistant platform always loaded. + await component.async_setup_platform( + HA_DOMAIN, {"platform": "homeasistant", STATES: []} + ) async def async_handle_scene_service(service): """Handle calls to the switch services.""" diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index ed1b41a0abd..b28beeaea72 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -114,7 +114,7 @@ class EntityComponent: # Look in config for Domain, Domain 2, Domain 3 etc and load them tasks = [] for p_type, p_config in config_per_platform(config, self.domain): - tasks.append(self._async_setup_platform(p_type, p_config)) + tasks.append(self.async_setup_platform(p_type, p_config)) if tasks: await asyncio.wait(tasks) @@ -123,7 +123,7 @@ class EntityComponent: # Refer to: homeassistant.components.discovery.load_platform() async def component_platform_discovered(platform, info): """Handle the loading of a platform.""" - await self._async_setup_platform(platform, {}, info) + await self.async_setup_platform(platform, {}, info) discovery.async_listen_platform( self.hass, self.domain, component_platform_discovered @@ -212,10 +212,13 @@ class EntityComponent: self.hass.services.async_register(self.domain, name, handle_service, schema) - async def _async_setup_platform( + async def async_setup_platform( self, platform_type, platform_config, discovery_info=None ): """Set up a platform for this component.""" + if self.config is None: + raise RuntimeError("async_setup needs to be called first") + platform = await async_prepare_setup_platform( self.hass, self.config, self.domain, platform_type ) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 5012f578106..ea71828f21a 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -1,5 +1,7 @@ """Class to manage the entities for a single platform.""" import asyncio +from contextvars import ContextVar +from typing import Optional from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import callback, valid_entity_id, split_entity_id @@ -127,6 +129,7 @@ class EntityPlatform: async_create_setup_task creates a coroutine that sets up platform. """ + current_platform.set(self) logger = self.logger hass = self.hass full_name = "{}.{}".format(self.domain, self.platform_name) @@ -457,3 +460,8 @@ class EntityPlatform: if tasks: await asyncio.wait(tasks) + + +current_platform: ContextVar[Optional[EntityPlatform]] = ContextVar( + "current_platform", default=None +) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c51b13100db..a1148063aee 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -7,6 +7,7 @@ async_timeout==3.0.1 attrs==19.1.0 bcrypt==3.1.7 certifi>=2019.6.16 +contextvars==2.4;python_version<"3.7" cryptography==2.7 distro==1.4.0 hass-nabucasa==0.16 diff --git a/requirements_all.txt b/requirements_all.txt index e986776fdb1..1fd445ea570 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -5,6 +5,7 @@ async_timeout==3.0.1 attrs==19.1.0 bcrypt==3.1.7 certifi>=2019.6.16 +contextvars==2.4;python_version<"3.7" importlib-metadata==0.18 jinja2>=2.10.1 PyJWT==1.7.1 diff --git a/setup.py b/setup.py index 14162a86c12..da50b5f988c 100755 --- a/setup.py +++ b/setup.py @@ -5,55 +5,55 @@ from setuptools import setup, find_packages import homeassistant.const as hass_const -PROJECT_NAME = 'Home Assistant' -PROJECT_PACKAGE_NAME = 'homeassistant' -PROJECT_LICENSE = 'Apache License 2.0' -PROJECT_AUTHOR = 'The Home Assistant Authors' -PROJECT_COPYRIGHT = ' 2013-{}, {}'.format(dt.now().year, PROJECT_AUTHOR) -PROJECT_URL = 'https://home-assistant.io/' -PROJECT_EMAIL = 'hello@home-assistant.io' +PROJECT_NAME = "Home Assistant" +PROJECT_PACKAGE_NAME = "homeassistant" +PROJECT_LICENSE = "Apache License 2.0" +PROJECT_AUTHOR = "The Home Assistant Authors" +PROJECT_COPYRIGHT = " 2013-{}, {}".format(dt.now().year, PROJECT_AUTHOR) +PROJECT_URL = "https://home-assistant.io/" +PROJECT_EMAIL = "hello@home-assistant.io" -PROJECT_GITHUB_USERNAME = 'home-assistant' -PROJECT_GITHUB_REPOSITORY = 'home-assistant' +PROJECT_GITHUB_USERNAME = "home-assistant" +PROJECT_GITHUB_REPOSITORY = "home-assistant" -PYPI_URL = 'https://pypi.python.org/pypi/{}'.format(PROJECT_PACKAGE_NAME) -GITHUB_PATH = '{}/{}'.format( - PROJECT_GITHUB_USERNAME, PROJECT_GITHUB_REPOSITORY) -GITHUB_URL = 'https://github.com/{}'.format(GITHUB_PATH) +PYPI_URL = "https://pypi.python.org/pypi/{}".format(PROJECT_PACKAGE_NAME) +GITHUB_PATH = "{}/{}".format(PROJECT_GITHUB_USERNAME, PROJECT_GITHUB_REPOSITORY) +GITHUB_URL = "https://github.com/{}".format(GITHUB_PATH) -DOWNLOAD_URL = '{}/archive/{}.zip'.format(GITHUB_URL, hass_const.__version__) +DOWNLOAD_URL = "{}/archive/{}.zip".format(GITHUB_URL, hass_const.__version__) PROJECT_URLS = { - 'Bug Reports': '{}/issues'.format(GITHUB_URL), - 'Dev Docs': 'https://developers.home-assistant.io/', - 'Discord': 'https://discordapp.com/invite/c5DvZ4e', - 'Forum': 'https://community.home-assistant.io/', + "Bug Reports": "{}/issues".format(GITHUB_URL), + "Dev Docs": "https://developers.home-assistant.io/", + "Discord": "https://discordapp.com/invite/c5DvZ4e", + "Forum": "https://community.home-assistant.io/", } -PACKAGES = find_packages(exclude=['tests', 'tests.*']) +PACKAGES = find_packages(exclude=["tests", "tests.*"]) REQUIRES = [ - 'aiohttp==3.5.4', - 'astral==1.10.1', - 'async_timeout==3.0.1', - 'attrs==19.1.0', - 'bcrypt==3.1.7', - 'certifi>=2019.6.16', - 'importlib-metadata==0.18', - 'jinja2>=2.10.1', - 'PyJWT==1.7.1', + "aiohttp==3.5.4", + "astral==1.10.1", + "async_timeout==3.0.1", + "attrs==19.1.0", + "bcrypt==3.1.7", + "certifi>=2019.6.16", + 'contextvars==2.4;python_version<"3.7"', + "importlib-metadata==0.18", + "jinja2>=2.10.1", + "PyJWT==1.7.1", # PyJWT has loose dependency. We want the latest one. - 'cryptography==2.7', - 'pip>=8.0.3', - 'python-slugify==3.0.2', - 'pytz>=2019.01', - 'pyyaml==5.1.1', - 'requests==2.22.0', - 'ruamel.yaml==0.15.99', - 'voluptuous==0.11.5', - 'voluptuous-serialize==2.1.0', + "cryptography==2.7", + "pip>=8.0.3", + "python-slugify==3.0.2", + "pytz>=2019.01", + "pyyaml==5.1.1", + "requests==2.22.0", + "ruamel.yaml==0.15.99", + "voluptuous==0.11.5", + "voluptuous-serialize==2.1.0", ] -MIN_PY_VERSION = '.'.join(map(str, hass_const.REQUIRED_PYTHON_VER)) +MIN_PY_VERSION = ".".join(map(str, hass_const.REQUIRED_PYTHON_VER)) setup( name=PROJECT_PACKAGE_NAME, @@ -67,11 +67,7 @@ setup( include_package_data=True, zip_safe=False, install_requires=REQUIRES, - python_requires='>={}'.format(MIN_PY_VERSION), - test_suite='tests', - entry_points={ - 'console_scripts': [ - 'hass = homeassistant.__main__:main' - ] - }, + python_requires=">={}".format(MIN_PY_VERSION), + test_suite="tests", + entry_points={"console_scripts": ["hass = homeassistant.__main__:main"]}, ) diff --git a/tests/components/homeassistant/test_scene.py b/tests/components/homeassistant/test_scene.py new file mode 100644 index 00000000000..02c018a0b49 --- /dev/null +++ b/tests/components/homeassistant/test_scene.py @@ -0,0 +1,30 @@ +"""Test Home Assistant scenes.""" +from unittest.mock import patch + +from homeassistant.setup import async_setup_component + + +async def test_reload_config_service(hass): + """Test the reload config service.""" + assert await async_setup_component(hass, "scene", {}) + + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value={"scene": {"name": "Hallo", "entities": {"light.kitchen": "on"}}}, + ), patch("homeassistant.config.find_config_file", return_value=""): + await hass.services.async_call("scene", "reload", blocking=True) + await hass.async_block_till_done() + + assert hass.states.get("scene.hallo") is not None + + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value={"scene": {"name": "Bye", "entities": {"light.kitchen": "on"}}}, + ), patch("homeassistant.config.find_config_file", return_value=""): + await hass.services.async_call("scene", "reload", blocking=True) + await hass.async_block_till_done() + + assert hass.states.get("scene.hallo") is None + assert hass.states.get("scene.bye") is not None diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 3dd6ca8b55f..0d52f430ff5 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -116,7 +116,7 @@ async def test_setup_recovers_when_setup_raises(hass): @asynctest.patch( - "homeassistant.helpers.entity_component.EntityComponent" "._async_setup_platform", + "homeassistant.helpers.entity_component.EntityComponent" ".async_setup_platform", return_value=mock_coro(), ) @asynctest.patch( -- GitLab