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