Adding id to lovelace cards in ui-lovelace.yaml (#17498)

* ID is added to cards without ID in ui-lovelace.yaml when loaded

* Hound

* Remove ui-lovelace.yaml

* Nicer get

* Update tests

* If YAML dump fails, config not gone

* Add tests

* Woof!

* Remove nosetests

* Address comments

* Woof...

* Delete test.yaml

* update rights to saved file

* fix

* line break
This commit is contained in:
Bram Kragten 2018-10-17 16:31:06 +02:00 committed by Paulus Schoutsen
parent 95c43d634b
commit 33860bf23c
5 changed files with 245 additions and 8 deletions

View File

@ -1,19 +1,95 @@
"""Lovelace UI.""" """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 import voluptuous as vol
from homeassistant.components import websocket_api from homeassistant.components import websocket_api
from homeassistant.util.yaml import load_yaml
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
_LOGGER = logging.getLogger(__name__)
DOMAIN = 'lovelace' DOMAIN = 'lovelace'
REQUIREMENTS = ['ruamel.yaml==0.15.72']
OLD_WS_TYPE_GET_LOVELACE_UI = 'frontend/lovelace_config' OLD_WS_TYPE_GET_LOVELACE_UI = 'frontend/lovelace_config'
WS_TYPE_GET_LOVELACE_UI = 'lovelace/config' WS_TYPE_GET_LOVELACE_UI = 'lovelace/config'
SCHEMA_GET_LOVELACE_UI = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ SCHEMA_GET_LOVELACE_UI = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
vol.Required('type'): vol.Any(WS_TYPE_GET_LOVELACE_UI, vol.Required('type'): vol.Any(WS_TYPE_GET_LOVELACE_UI,
OLD_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): async def async_setup(hass, config):
"""Set up the Lovelace commands.""" """Set up the Lovelace commands."""
@ -35,7 +111,7 @@ async def websocket_lovelace_config(hass, connection, msg):
error = None error = None
try: try:
config = await hass.async_add_executor_job( 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( message = websocket_api.result_message(
msg['id'], config msg['id'], config
) )

View File

@ -1308,6 +1308,9 @@ roombapy==1.3.1
# homeassistant.components.switch.rpi_rf # homeassistant.components.switch.rpi_rf
# rpi-rf==0.9.6 # rpi-rf==0.9.6
# homeassistant.components.lovelace
ruamel.yaml==0.15.72
# homeassistant.components.media_player.russound_rnet # homeassistant.components.media_player.russound_rnet
russound==0.1.9 russound==0.1.9

View File

@ -215,6 +215,9 @@ rflink==0.0.37
# homeassistant.components.ring # homeassistant.components.ring
ring_doorbell==0.2.1 ring_doorbell==0.2.1
# homeassistant.components.lovelace
ruamel.yaml==0.15.72
# homeassistant.components.media_player.yamaha # homeassistant.components.media_player.yamaha
rxv==0.5.1 rxv==0.5.1

View File

@ -111,6 +111,7 @@ TEST_REQUIREMENTS = (
'wakeonlan', 'wakeonlan',
'vultr', 'vultr',
'YesssSMS', 'YesssSMS',
'ruamel.yaml',
) )
IGNORE_PACKAGES = ( IGNORE_PACKAGES = (

View File

@ -1,9 +1,163 @@
"""Test the Lovelace initialization.""" """Test the Lovelace initialization."""
import os
import unittest
from unittest.mock import patch from unittest.mock import patch
from tempfile import mkdtemp
from ruamel.yaml import YAML
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.components.websocket_api.const import TYPE_RESULT 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): 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') await async_setup_component(hass, 'lovelace')
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
with patch('homeassistant.components.lovelace.load_yaml', with patch('homeassistant.components.lovelace.load_config',
return_value={'hello': 'world'}): return_value={'hello': 'world'}):
await client.send_json({ await client.send_json({
'id': 5, 'id': 5,
@ -30,7 +184,7 @@ async def test_deprecated_lovelace_ui_not_found(hass, hass_ws_client):
await async_setup_component(hass, 'lovelace') await async_setup_component(hass, 'lovelace')
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
with patch('homeassistant.components.lovelace.load_yaml', with patch('homeassistant.components.lovelace.load_config',
side_effect=FileNotFoundError): side_effect=FileNotFoundError):
await client.send_json({ await client.send_json({
'id': 5, 'id': 5,
@ -49,7 +203,7 @@ async def test_deprecated_lovelace_ui_load_err(hass, hass_ws_client):
await async_setup_component(hass, 'lovelace') await async_setup_component(hass, 'lovelace')
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
with patch('homeassistant.components.lovelace.load_yaml', with patch('homeassistant.components.lovelace.load_config',
side_effect=HomeAssistantError): side_effect=HomeAssistantError):
await client.send_json({ await client.send_json({
'id': 5, 'id': 5,
@ -68,7 +222,7 @@ async def test_lovelace_ui(hass, hass_ws_client):
await async_setup_component(hass, 'lovelace') await async_setup_component(hass, 'lovelace')
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
with patch('homeassistant.components.lovelace.load_yaml', with patch('homeassistant.components.lovelace.load_config',
return_value={'hello': 'world'}): return_value={'hello': 'world'}):
await client.send_json({ await client.send_json({
'id': 5, 'id': 5,
@ -87,7 +241,7 @@ async def test_lovelace_ui_not_found(hass, hass_ws_client):
await async_setup_component(hass, 'lovelace') await async_setup_component(hass, 'lovelace')
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
with patch('homeassistant.components.lovelace.load_yaml', with patch('homeassistant.components.lovelace.load_config',
side_effect=FileNotFoundError): side_effect=FileNotFoundError):
await client.send_json({ await client.send_json({
'id': 5, 'id': 5,
@ -106,7 +260,7 @@ async def test_lovelace_ui_load_err(hass, hass_ws_client):
await async_setup_component(hass, 'lovelace') await async_setup_component(hass, 'lovelace')
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
with patch('homeassistant.components.lovelace.load_yaml', with patch('homeassistant.components.lovelace.load_config',
side_effect=HomeAssistantError): side_effect=HomeAssistantError):
await client.send_json({ await client.send_json({
'id': 5, 'id': 5,