diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index a24c8eb9e91..e3f4522580b 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -1,19 +1,95 @@ """Lovelace UI.""" +import logging +import uuid +import os +from os import O_WRONLY, O_CREAT, O_TRUNC +from collections import OrderedDict +from typing import Union, List, Dict import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.util.yaml import load_yaml from homeassistant.exceptions import HomeAssistantError +_LOGGER = logging.getLogger(__name__) DOMAIN = 'lovelace' +REQUIREMENTS = ['ruamel.yaml==0.15.72'] OLD_WS_TYPE_GET_LOVELACE_UI = 'frontend/lovelace_config' WS_TYPE_GET_LOVELACE_UI = 'lovelace/config' + SCHEMA_GET_LOVELACE_UI = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): vol.Any(WS_TYPE_GET_LOVELACE_UI, OLD_WS_TYPE_GET_LOVELACE_UI), }) +JSON_TYPE = Union[List, Dict, str] # pylint: disable=invalid-name + + +class WriteError(HomeAssistantError): + """Error writing the data.""" + + +def save_yaml(fname: str, data: JSON_TYPE): + """Save a YAML file.""" + from ruamel.yaml import YAML + from ruamel.yaml.error import YAMLError + yaml = YAML(typ='rt') + yaml.indent(sequence=4, offset=2) + tmp_fname = fname + "__TEMP__" + try: + with open(os.open(tmp_fname, O_WRONLY | O_CREAT | O_TRUNC, 0o644), + 'w', encoding='utf-8') as temp_file: + yaml.dump(data, temp_file) + os.replace(tmp_fname, fname) + except YAMLError as exc: + _LOGGER.error(str(exc)) + raise HomeAssistantError(exc) + except OSError as exc: + _LOGGER.exception('Saving YAML file failed: %s', fname) + raise WriteError(exc) + finally: + if os.path.exists(tmp_fname): + try: + os.remove(tmp_fname) + except OSError as exc: + # If we are cleaning up then something else went wrong, so + # we should suppress likely follow-on errors in the cleanup + _LOGGER.error("YAML replacement cleanup failed: %s", exc) + + +def load_yaml(fname: str) -> JSON_TYPE: + """Load a YAML file.""" + from ruamel.yaml import YAML + from ruamel.yaml.error import YAMLError + yaml = YAML(typ='rt') + try: + with open(fname, encoding='utf-8') as conf_file: + # If configuration file is empty YAML returns None + # We convert that to an empty dict + return yaml.load(conf_file) or OrderedDict() + except YAMLError as exc: + _LOGGER.error("YAML error: %s", exc) + raise HomeAssistantError(exc) + except UnicodeDecodeError as exc: + _LOGGER.error("Unable to read file %s: %s", fname, exc) + raise HomeAssistantError(exc) + + +def load_config(fname: str) -> JSON_TYPE: + """Load a YAML file and adds id to card if not present.""" + config = load_yaml(fname) + # Check if all cards have an ID or else add one + updated = False + for view in config.get('views', []): + for card in view.get('cards', []): + if 'id' not in card: + updated = True + card['id'] = uuid.uuid4().hex + card.move_to_end('id', last=False) + if updated: + save_yaml(fname, config) + return config + async def async_setup(hass, config): """Set up the Lovelace commands.""" @@ -35,7 +111,7 @@ async def websocket_lovelace_config(hass, connection, msg): error = None try: config = await hass.async_add_executor_job( - load_yaml, hass.config.path('ui-lovelace.yaml')) + load_config, hass.config.path('ui-lovelace.yaml')) message = websocket_api.result_message( msg['id'], config ) diff --git a/requirements_all.txt b/requirements_all.txt index a48c53fa5b4..6dfc1ee5713 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1308,6 +1308,9 @@ roombapy==1.3.1 # homeassistant.components.switch.rpi_rf # rpi-rf==0.9.6 +# homeassistant.components.lovelace +ruamel.yaml==0.15.72 + # homeassistant.components.media_player.russound_rnet russound==0.1.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5d462b3b0d5..746b9a39d02 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -215,6 +215,9 @@ rflink==0.0.37 # homeassistant.components.ring ring_doorbell==0.2.1 +# homeassistant.components.lovelace +ruamel.yaml==0.15.72 + # homeassistant.components.media_player.yamaha rxv==0.5.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 47d11dff582..491531ee12b 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -111,6 +111,7 @@ TEST_REQUIREMENTS = ( 'wakeonlan', 'vultr', 'YesssSMS', + 'ruamel.yaml', ) IGNORE_PACKAGES = ( diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index 0fde6de902c..5e4cf2d8037 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -1,9 +1,163 @@ """Test the Lovelace initialization.""" +import os +import unittest from unittest.mock import patch +from tempfile import mkdtemp +from ruamel.yaml import YAML from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.components.lovelace import (load_yaml, + save_yaml, load_config) + +TEST_YAML_A = """\ +title: My Awesome Home +# Include external resources +resources: + - url: /local/my-custom-card.js + type: js + - url: /local/my-webfont.css + type: css + +# Exclude entities from "Unused entities" view +excluded_entities: + - weblink.router +views: + # View tab title. + - title: Example + # Optional unique id for direct access /lovelace/${id} + id: example + # Optional background (overwrites the global background). + background: radial-gradient(crimson, skyblue) + # Each view can have a different theme applied. + theme: dark-mode + # The cards to show on this view. + cards: + # The filter card will filter entities for their state + - type: entity-filter + entities: + - device_tracker.paulus + - device_tracker.anne_there + state_filter: + - 'home' + card: + type: glance + title: People that are home + + # The picture entity card will represent an entity with a picture + - type: picture-entity + image: https://www.home-assistant.io/images/default-social.png + entity: light.bed_light + + # Specify a tab icon if you want the view tab to be an icon. + - icon: mdi:home-assistant + # Title of the view. Will be used as the tooltip for tab icon + title: Second view + cards: + # Entities card will take a list of entities and show their state. + - type: entities + # Title of the entities card + title: Example + # The entities here will be shown in the same order as specified. + # Each entry is an entity ID or a map with extra options. + entities: + - light.kitchen + - switch.ac + - entity: light.living_room + # Override the name to use + name: LR Lights + + # The markdown card will render markdown text. + - type: markdown + title: Lovelace + content: > + Welcome to your **Lovelace UI**. +""" + +TEST_YAML_B = """\ +title: Home +views: + - title: Dashboard + icon: mdi:home + cards: + - id: testid + type: vertical-stack + cards: + - type: picture-entity + entity: group.sample + name: Sample + image: /local/images/sample.jpg + tap_action: toggle +""" + +# Test data that can not be loaded as YAML +TEST_BAD_YAML = """\ +title: Home +views: + - title: Dashboard + icon: mdi:home + cards: + - id: testid + type: vertical-stack +""" + + +class TestYAML(unittest.TestCase): + """Test lovelace.yaml save and load.""" + + def setUp(self): + """Set up for tests.""" + self.tmp_dir = mkdtemp() + self.yaml = YAML(typ='rt') + + def tearDown(self): + """Clean up after tests.""" + for fname in os.listdir(self.tmp_dir): + os.remove(os.path.join(self.tmp_dir, fname)) + os.rmdir(self.tmp_dir) + + def _path_for(self, leaf_name): + return os.path.join(self.tmp_dir, leaf_name+".yaml") + + def test_save_and_load(self): + """Test saving and loading back.""" + fname = self._path_for("test1") + save_yaml(fname, self.yaml.load(TEST_YAML_A)) + data = load_yaml(fname) + self.assertEqual(data, self.yaml.load(TEST_YAML_A)) + + def test_overwrite_and_reload(self): + """Test that we can overwrite an existing file and read back.""" + fname = self._path_for("test3") + save_yaml(fname, self.yaml.load(TEST_YAML_A)) + save_yaml(fname, self.yaml.load(TEST_YAML_B)) + data = load_yaml(fname) + self.assertEqual(data, self.yaml.load(TEST_YAML_B)) + + def test_load_bad_data(self): + """Test error from trying to load unserialisable data.""" + fname = self._path_for("test5") + with open(fname, "w") as fh: + fh.write(TEST_BAD_YAML) + with self.assertRaises(HomeAssistantError): + load_yaml(fname) + + def test_add_id(self): + """Test if id is added.""" + fname = self._path_for("test6") + with patch('homeassistant.components.lovelace.load_yaml', + return_value=self.yaml.load(TEST_YAML_A)): + data = load_config(fname) + assert 'id' in data['views'][0]['cards'][0] + + def test_id_not_changed(self): + """Test if id is not changed if already exists.""" + fname = self._path_for("test7") + with patch('homeassistant.components.lovelace.load_yaml', + return_value=self.yaml.load(TEST_YAML_B)): + data = load_config(fname) + self.assertEqual(data, self.yaml.load(TEST_YAML_B)) async def test_deprecated_lovelace_ui(hass, hass_ws_client): @@ -11,7 +165,7 @@ async def test_deprecated_lovelace_ui(hass, hass_ws_client): await async_setup_component(hass, 'lovelace') client = await hass_ws_client(hass) - with patch('homeassistant.components.lovelace.load_yaml', + with patch('homeassistant.components.lovelace.load_config', return_value={'hello': 'world'}): await client.send_json({ 'id': 5, @@ -30,7 +184,7 @@ async def test_deprecated_lovelace_ui_not_found(hass, hass_ws_client): await async_setup_component(hass, 'lovelace') client = await hass_ws_client(hass) - with patch('homeassistant.components.lovelace.load_yaml', + with patch('homeassistant.components.lovelace.load_config', side_effect=FileNotFoundError): await client.send_json({ 'id': 5, @@ -49,7 +203,7 @@ async def test_deprecated_lovelace_ui_load_err(hass, hass_ws_client): await async_setup_component(hass, 'lovelace') client = await hass_ws_client(hass) - with patch('homeassistant.components.lovelace.load_yaml', + with patch('homeassistant.components.lovelace.load_config', side_effect=HomeAssistantError): await client.send_json({ 'id': 5, @@ -68,7 +222,7 @@ async def test_lovelace_ui(hass, hass_ws_client): await async_setup_component(hass, 'lovelace') client = await hass_ws_client(hass) - with patch('homeassistant.components.lovelace.load_yaml', + with patch('homeassistant.components.lovelace.load_config', return_value={'hello': 'world'}): await client.send_json({ 'id': 5, @@ -87,7 +241,7 @@ async def test_lovelace_ui_not_found(hass, hass_ws_client): await async_setup_component(hass, 'lovelace') client = await hass_ws_client(hass) - with patch('homeassistant.components.lovelace.load_yaml', + with patch('homeassistant.components.lovelace.load_config', side_effect=FileNotFoundError): await client.send_json({ 'id': 5, @@ -106,7 +260,7 @@ async def test_lovelace_ui_load_err(hass, hass_ws_client): await async_setup_component(hass, 'lovelace') client = await hass_ws_client(hass) - with patch('homeassistant.components.lovelace.load_yaml', + with patch('homeassistant.components.lovelace.load_config', side_effect=HomeAssistantError): await client.send_json({ 'id': 5,