Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 78 additions & 2 deletions homeassistant/components/lovelace/__init__.py
Original file line number Diff line number Diff line change
@@ -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):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So there is a lot of copy paste going on in these methods. I think that it's fine for now.

A bunch of that will go away once we migrate the util.yaml to ruamel.yaml, as it implements YAML 1.2 and not YAML 1.1.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

btw, I can only recommend ruamel.yaml. Recently started porting esphomeyaml to use it and it's rather simple - since esphomeyaml uses almost the same loader/yaml_util code (😄), I can then also port it to home assistant

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be awesome Otto.

"""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."""
Expand All @@ -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
)
Expand Down
3 changes: 3 additions & 0 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1305,6 +1305,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

Expand Down
3 changes: 3 additions & 0 deletions requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,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

Expand Down
1 change: 1 addition & 0 deletions script/gen_requirements_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
'wakeonlan',
'vultr',
'YesssSMS',
'ruamel.yaml',
)

IGNORE_PACKAGES = (
Expand Down
166 changes: 160 additions & 6 deletions tests/components/lovelace/test_init.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,171 @@
"""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):
"""Test lovelace_ui command."""
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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down