diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 569d1de6a02..5a66c1fc5d4 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -25,6 +25,7 @@ SECTIONS = ( "entity_registry", "group", "script", + "scene", ) ON_DEMAND = ("zwave",) diff --git a/homeassistant/components/config/scene.py b/homeassistant/components/config/scene.py new file mode 100644 index 00000000000..6e77dae0826 --- /dev/null +++ b/homeassistant/components/config/scene.py @@ -0,0 +1,65 @@ +"""Provide configuration end points for Scenes.""" +from collections import OrderedDict +import uuid + +from homeassistant.components.scene import DOMAIN, PLATFORM_SCHEMA +from homeassistant.const import CONF_ID, SERVICE_RELOAD +from homeassistant.config import SCENE_CONFIG_PATH +import homeassistant.helpers.config_validation as cv + +from . import EditIdBasedConfigView + + +async def async_setup(hass): + """Set up the Scene config API.""" + + async def hook(hass): + """post_write_hook for Config View that reloads scenes.""" + await hass.services.async_call(DOMAIN, SERVICE_RELOAD) + + hass.http.register_view( + EditSceneConfigView( + DOMAIN, + "config", + SCENE_CONFIG_PATH, + cv.string, + PLATFORM_SCHEMA, + post_write_hook=hook, + ) + ) + return True + + +class EditSceneConfigView(EditIdBasedConfigView): + """Edit scene config.""" + + def _write_value(self, hass, data, config_key, new_value): + """Set value.""" + index = None + for index, cur_value in enumerate(data): + # When people copy paste their scenes to the config file, + # they sometimes forget to add IDs. Fix it here. + if CONF_ID not in cur_value: + cur_value[CONF_ID] = uuid.uuid4().hex + + elif cur_value[CONF_ID] == config_key: + break + else: + cur_value = dict() + cur_value[CONF_ID] = config_key + index = len(data) + data.append(cur_value) + + # Iterate through some keys that we want to have ordered in the output + updated_value = OrderedDict() + for key in ("id", "name", "entities"): + if key in cur_value: + updated_value[key] = cur_value[key] + if key in new_value: + updated_value[key] = new_value[key] + + # We cover all current fields above, but just in case we start + # supporting more fields in the future. + updated_value.update(cur_value) + updated_value.update(new_value) + data[index] = updated_value diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py index 084c950bf17..f011dae150f 100644 --- a/homeassistant/components/homeassistant/scene.py +++ b/homeassistant/components/homeassistant/scene.py @@ -8,6 +8,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_STATE, CONF_ENTITIES, + CONF_ID, CONF_NAME, CONF_PLATFORM, STATE_OFF, @@ -160,7 +161,11 @@ def _process_scenes_config(hass, async_add_entities, config): return async_add_entities( - HomeAssistantScene(hass, SCENECONFIG(scene[CONF_NAME], scene[CONF_ENTITIES])) + HomeAssistantScene( + hass, + SCENECONFIG(scene[CONF_NAME], scene[CONF_ENTITIES]), + scene.get(CONF_ID), + ) for scene in scene_config ) @@ -168,8 +173,9 @@ def _process_scenes_config(hass, async_add_entities, config): class HomeAssistantScene(Scene): """A scene is a group of entities and the states we want them to be.""" - def __init__(self, hass, scene_config): + def __init__(self, hass, scene_config, scene_id=None): """Initialize the scene.""" + self._id = scene_id self.hass = hass self.scene_config = scene_config @@ -181,7 +187,10 @@ class HomeAssistantScene(Scene): @property def device_state_attributes(self): """Return the scene state attributes.""" - return {ATTR_ENTITY_ID: list(self.scene_config.states)} + attributes = {ATTR_ENTITY_ID: list(self.scene_config.states)} + if self._id is not None: + attributes[CONF_ID] = self._id + return attributes async def async_activate(self): """Activate scene. Try to get entities into requested state.""" diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 9d46238b241..f30ee816914 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -132,7 +132,9 @@ async def handle_call_service(hass, connection, msg): blocking, connection.context(msg), ) - connection.send_message(messages.result_message(msg["id"])) + connection.send_message( + messages.result_message(msg["id"], {"context": connection.context(msg)}) + ) except ServiceNotFound as err: if err.domain == msg["domain"] and err.service == msg["service"]: connection.send_message( diff --git a/homeassistant/config.py b/homeassistant/config.py index 9f49889791e..864ced6a16a 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -69,6 +69,7 @@ DATA_CUSTOMIZE = "hass_customize" GROUP_CONFIG_PATH = "groups.yaml" AUTOMATION_CONFIG_PATH = "automations.yaml" SCRIPT_CONFIG_PATH = "scripts.yaml" +SCENE_CONFIG_PATH = "scenes.yaml" DEFAULT_CONFIG = f""" # Configure a default setup of Home Assistant (frontend, api, etc) @@ -85,6 +86,7 @@ tts: group: !include {GROUP_CONFIG_PATH} automation: !include {AUTOMATION_CONFIG_PATH} script: !include {SCRIPT_CONFIG_PATH} +scene: !include {SCENE_CONFIG_PATH} """ DEFAULT_SECRETS = """ # Use this file to store secrets like usernames and passwords. @@ -261,6 +263,7 @@ def _write_default_config(config_dir: str) -> Optional[str]: group_yaml_path = os.path.join(config_dir, GROUP_CONFIG_PATH) automation_yaml_path = os.path.join(config_dir, AUTOMATION_CONFIG_PATH) script_yaml_path = os.path.join(config_dir, SCRIPT_CONFIG_PATH) + scene_yaml_path = os.path.join(config_dir, SCENE_CONFIG_PATH) # Writing files with YAML does not create the most human readable results # So we're hard coding a YAML template. @@ -283,6 +286,9 @@ def _write_default_config(config_dir: str) -> Optional[str]: with open(script_yaml_path, "wt"): pass + with open(scene_yaml_path, "wt"): + pass + return config_path except OSError: diff --git a/tests/components/config/test_scene.py b/tests/components/config/test_scene.py new file mode 100644 index 00000000000..b40c895b620 --- /dev/null +++ b/tests/components/config/test_scene.py @@ -0,0 +1,144 @@ +"""Test Automation config panel.""" +import json +from unittest.mock import patch + +from homeassistant.bootstrap import async_setup_component +from homeassistant.components import config +from homeassistant.util.yaml import dump + + +async def test_update_scene(hass, hass_client): + """Test updating a scene.""" + with patch.object(config, "SECTIONS", ["scene"]): + await async_setup_component(hass, "config", {}) + + client = await hass_client() + + orig_data = [{"id": "light_on"}, {"id": "light_off"}] + + def mock_read(path): + """Mock reading data.""" + return orig_data + + written = [] + + def mock_write(path, data): + """Mock writing data.""" + data = dump(data) + written.append(data) + + with patch("homeassistant.components.config._read", mock_read), patch( + "homeassistant.components.config._write", mock_write + ): + resp = await client.post( + "/api/config/scene/config/light_off", + data=json.dumps( + { + "id": "light_off", + "name": "Lights off", + "entities": {"light.bedroom": {"state": "off"}}, + } + ), + ) + + assert resp.status == 200 + result = await resp.json() + assert result == {"result": "ok"} + + assert len(written) == 1 + written_yaml = written[0] + assert ( + written_yaml + == """- id: light_on +- id: light_off + name: Lights off + entities: + light.bedroom: + state: 'off' +""" + ) + + +async def test_bad_formatted_scene(hass, hass_client): + """Test that we handle scene without ID.""" + with patch.object(config, "SECTIONS", ["scene"]): + await async_setup_component(hass, "config", {}) + + client = await hass_client() + + orig_data = [ + { + # No ID + "entities": {"light.bedroom": "on"} + }, + {"id": "light_off"}, + ] + + def mock_read(path): + """Mock reading data.""" + return orig_data + + written = [] + + def mock_write(path, data): + """Mock writing data.""" + written.append(data) + + with patch("homeassistant.components.config._read", mock_read), patch( + "homeassistant.components.config._write", mock_write + ): + resp = await client.post( + "/api/config/scene/config/light_off", + data=json.dumps( + { + "id": "light_off", + "name": "Lights off", + "entities": {"light.bedroom": {"state": "off"}}, + } + ), + ) + + assert resp.status == 200 + result = await resp.json() + assert result == {"result": "ok"} + + # Verify ID added to orig_data + assert "id" in orig_data[0] + + assert orig_data[1] == { + "id": "light_off", + "name": "Lights off", + "entities": {"light.bedroom": {"state": "off"}}, + } + + +async def test_delete_scene(hass, hass_client): + """Test deleting a scene.""" + with patch.object(config, "SECTIONS", ["scene"]): + await async_setup_component(hass, "config", {}) + + client = await hass_client() + + orig_data = [{"id": "light_on"}, {"id": "light_off"}] + + def mock_read(path): + """Mock reading data.""" + return orig_data + + written = [] + + def mock_write(path, data): + """Mock writing data.""" + written.append(data) + + with patch("homeassistant.components.config._read", mock_read), patch( + "homeassistant.components.config._write", mock_write + ): + resp = await client.delete("/api/config/scene/config/light_on") + + assert resp.status == 200 + result = await resp.json() + assert result == {"result": "ok"} + + assert len(written) == 1 + assert written[0][0]["id"] == "light_off" diff --git a/tests/test_config.py b/tests/test_config.py index dab51f59176..1c872369096 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -44,6 +44,7 @@ VERSION_PATH = os.path.join(CONFIG_DIR, config_util.VERSION_FILE) GROUP_PATH = os.path.join(CONFIG_DIR, config_util.GROUP_CONFIG_PATH) AUTOMATIONS_PATH = os.path.join(CONFIG_DIR, config_util.AUTOMATION_CONFIG_PATH) SCRIPTS_PATH = os.path.join(CONFIG_DIR, config_util.SCRIPT_CONFIG_PATH) +SCENES_PATH = os.path.join(CONFIG_DIR, config_util.SCENE_CONFIG_PATH) ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE @@ -75,6 +76,9 @@ def teardown(): if os.path.isfile(SCRIPTS_PATH): os.remove(SCRIPTS_PATH) + if os.path.isfile(SCENES_PATH): + os.remove(SCENES_PATH) + async def test_create_default_config(hass): """Test creation of default config."""