Lovelace ws: add move command (#17806)

* Check for unique ids + ids are strings

* Add move command

* Add test for move

* lint

* more lint

* Address comments

* Update test
This commit is contained in:
Bram Kragten 2018-10-26 12:56:14 +02:00 committed by Paulus Schoutsen
parent cce8b1183f
commit b7896491e3
2 changed files with 205 additions and 10 deletions

View File

@ -27,6 +27,7 @@ WS_TYPE_GET_LOVELACE_UI = 'lovelace/config'
WS_TYPE_GET_CARD = 'lovelace/config/card/get' WS_TYPE_GET_CARD = 'lovelace/config/card/get'
WS_TYPE_UPDATE_CARD = 'lovelace/config/card/update' WS_TYPE_UPDATE_CARD = 'lovelace/config/card/update'
WS_TYPE_ADD_CARD = 'lovelace/config/card/add' WS_TYPE_ADD_CARD = 'lovelace/config/card/add'
WS_TYPE_MOVE_CARD = 'lovelace/config/card/move'
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,
@ -57,6 +58,13 @@ SCHEMA_ADD_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
FORMAT_YAML), FORMAT_YAML),
}) })
SCHEMA_MOVE_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
vol.Required('type'): WS_TYPE_MOVE_CARD,
vol.Required('card_id'): str,
vol.Optional('new_position'): int,
vol.Optional('new_view_id'): str,
})
class WriteError(HomeAssistantError): class WriteError(HomeAssistantError):
"""Error writing the data.""" """Error writing the data."""
@ -74,6 +82,10 @@ class UnsupportedYamlError(HomeAssistantError):
"""Unsupported YAML.""" """Unsupported YAML."""
class DuplicateIdError(HomeAssistantError):
"""Duplicate ID's."""
def save_yaml(fname: str, data: JSON_TYPE): def save_yaml(fname: str, data: JSON_TYPE):
"""Save a YAML file.""" """Save a YAML file."""
from ruamel.yaml import YAML from ruamel.yaml import YAML
@ -134,19 +146,34 @@ def load_yaml(fname: str) -> JSON_TYPE:
def load_config(fname: str) -> JSON_TYPE: def load_config(fname: str) -> JSON_TYPE:
"""Load a YAML file and adds id to views and cards if not present.""" """Load a YAML file and adds id to views and cards if not present."""
config = load_yaml(fname) config = load_yaml(fname)
# Check if all views and cards have an id or else add one # Check if all views and cards have a unique id or else add one
updated = False updated = False
seen_card_ids = set()
seen_view_ids = set()
index = 0 index = 0
for view in config.get('views', []): for view in config.get('views', []):
if 'id' not in view: view_id = view.get('id')
if view_id is None:
updated = True updated = True
view.insert(0, 'id', index, view.insert(0, 'id', index,
comment="Automatically created id") comment="Automatically created id")
else:
if view_id in seen_view_ids:
raise DuplicateIdError(
'ID `{}` has multiple occurances in views'.format(view_id))
seen_view_ids.add(view_id)
for card in view.get('cards', []): for card in view.get('cards', []):
if 'id' not in card: card_id = card.get('id')
if card_id is None:
updated = True updated = True
card.insert(0, 'id', uuid.uuid4().hex, card.insert(0, 'id', uuid.uuid4().hex,
comment="Automatically created id") comment="Automatically created id")
else:
if card_id in seen_card_ids:
raise DuplicateIdError(
'ID `{}` has multiple occurances in cards'
.format(card_id))
seen_card_ids.add(card_id)
index += 1 index += 1
if updated: if updated:
save_yaml(fname, config) save_yaml(fname, config)
@ -187,7 +214,7 @@ def get_card(fname: str, card_id: str, data_format: str = FORMAT_YAML)\
config = load_yaml(fname) config = load_yaml(fname)
for view in config.get('views', []): for view in config.get('views', []):
for card in view.get('cards', []): for card in view.get('cards', []):
if card.get('id') != card_id: if str(card.get('id')) != card_id:
continue continue
if data_format == FORMAT_YAML: if data_format == FORMAT_YAML:
return object_to_yaml(card) return object_to_yaml(card)
@ -203,7 +230,7 @@ def update_card(fname: str, card_id: str, card_config: str,
config = load_yaml(fname) config = load_yaml(fname)
for view in config.get('views', []): for view in config.get('views', []):
for card in view.get('cards', []): for card in view.get('cards', []):
if card.get('id') != card_id: if str(card.get('id')) != card_id:
continue continue
if data_format == FORMAT_YAML: if data_format == FORMAT_YAML:
card_config = yaml_to_object(card_config) card_config = yaml_to_object(card_config)
@ -220,7 +247,7 @@ def add_card(fname: str, view_id: str, card_config: str,
"""Add a card to a view.""" """Add a card to a view."""
config = load_yaml(fname) config = load_yaml(fname)
for view in config.get('views', []): for view in config.get('views', []):
if view.get('id') != view_id: if str(view.get('id')) != view_id:
continue continue
cards = view.get('cards', []) cards = view.get('cards', [])
if data_format == FORMAT_YAML: if data_format == FORMAT_YAML:
@ -236,6 +263,55 @@ def add_card(fname: str, view_id: str, card_config: str,
"View with ID: {} was not found in {}.".format(view_id, fname)) "View with ID: {} was not found in {}.".format(view_id, fname))
def move_card(fname: str, card_id: str, position: int = None):
"""Move a card to a different position."""
if position is None:
raise HomeAssistantError('Position is required if view is not\
specified.')
config = load_yaml(fname)
for view in config.get('views', []):
for card in view.get('cards', []):
if str(card.get('id')) != card_id:
continue
cards = view.get('cards')
cards.insert(position, cards.pop(cards.index(card)))
save_yaml(fname, config)
return
raise CardNotFoundError(
"Card with ID: {} was not found in {}.".format(card_id, fname))
def move_card_view(fname: str, card_id: str, view_id: str,
position: int = None):
"""Move a card to a different view."""
config = load_yaml(fname)
for view in config.get('views', []):
if str(view.get('id')) == view_id:
destination = view.get('cards')
for card in view.get('cards'):
if str(card.get('id')) != card_id:
continue
origin = view.get('cards')
card_to_move = card
if 'destination' not in locals():
raise ViewNotFoundError(
"View with ID: {} was not found in {}.".format(view_id, fname))
if 'card_to_move' not in locals():
raise CardNotFoundError(
"Card with ID: {} was not found in {}.".format(card_id, fname))
origin.pop(origin.index(card_to_move))
if position is None:
destination.append(card_to_move)
else:
destination.insert(position, card_to_move)
save_yaml(fname, config)
async def async_setup(hass, config): async def async_setup(hass, config):
"""Set up the Lovelace commands.""" """Set up the Lovelace commands."""
# Backwards compat. Added in 0.80. Remove after 0.85 # Backwards compat. Added in 0.80. Remove after 0.85
@ -259,6 +335,10 @@ async def async_setup(hass, config):
WS_TYPE_ADD_CARD, websocket_lovelace_add_card, WS_TYPE_ADD_CARD, websocket_lovelace_add_card,
SCHEMA_ADD_CARD) SCHEMA_ADD_CARD)
hass.components.websocket_api.async_register_command(
WS_TYPE_MOVE_CARD, websocket_lovelace_move_card,
SCHEMA_MOVE_CARD)
return True return True
@ -322,7 +402,7 @@ async def websocket_lovelace_update_card(hass, connection, msg):
update_card, hass.config.path(LOVELACE_CONFIG_FILE), update_card, hass.config.path(LOVELACE_CONFIG_FILE),
msg['card_id'], msg['card_config'], msg.get('format', FORMAT_YAML)) msg['card_id'], msg['card_config'], msg.get('format', FORMAT_YAML))
message = websocket_api.result_message( message = websocket_api.result_message(
msg['id'], True msg['id']
) )
except FileNotFoundError: except FileNotFoundError:
error = ('file_not_found', error = ('file_not_found',
@ -350,7 +430,7 @@ async def websocket_lovelace_add_card(hass, connection, msg):
msg['view_id'], msg['card_config'], msg.get('position'), msg['view_id'], msg['card_config'], msg.get('position'),
msg.get('format', FORMAT_YAML)) msg.get('format', FORMAT_YAML))
message = websocket_api.result_message( message = websocket_api.result_message(
msg['id'], True msg['id']
) )
except FileNotFoundError: except FileNotFoundError:
error = ('file_not_found', error = ('file_not_found',
@ -366,3 +446,38 @@ async def websocket_lovelace_add_card(hass, connection, msg):
message = websocket_api.error_message(msg['id'], *error) message = websocket_api.error_message(msg['id'], *error)
connection.send_message(message) connection.send_message(message)
@websocket_api.async_response
async def websocket_lovelace_move_card(hass, connection, msg):
"""Move card to different position over websocket and save."""
error = None
try:
if 'new_view_id' in msg:
await hass.async_add_executor_job(
move_card_view, hass.config.path(LOVELACE_CONFIG_FILE),
msg['card_id'], msg['new_view_id'], msg.get('new_position'))
else:
await hass.async_add_executor_job(
move_card, hass.config.path(LOVELACE_CONFIG_FILE),
msg['card_id'], msg.get('new_position'))
message = websocket_api.result_message(
msg['id']
)
except FileNotFoundError:
error = ('file_not_found',
'Could not find ui-lovelace.yaml in your config dir.')
except UnsupportedYamlError as err:
error = 'unsupported_error', str(err)
except ViewNotFoundError as err:
error = 'view_not_found', str(err)
except CardNotFoundError as err:
error = 'card_not_found', str(err)
except HomeAssistantError as err:
error = 'save_error', str(err)
if error is not None:
message = websocket_api.error_message(msg['id'], *error)
connection.send_message(message)

View File

@ -3,6 +3,7 @@ import os
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from tempfile import mkdtemp from tempfile import mkdtemp
import pytest
from ruamel.yaml import YAML from ruamel.yaml import YAML
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
@ -11,7 +12,6 @@ from homeassistant.components.websocket_api.const import TYPE_RESULT
from homeassistant.components.lovelace import (load_yaml, from homeassistant.components.lovelace import (load_yaml,
save_yaml, load_config, save_yaml, load_config,
UnsupportedYamlError) UnsupportedYamlError)
import pytest
TEST_YAML_A = """\ TEST_YAML_A = """\
title: My Awesome Home title: My Awesome Home
@ -59,6 +59,7 @@ views:
cards: cards:
- id: test - id: test
type: entities type: entities
title: Test card
# Entities card will take a list of entities and show their state. # Entities card will take a list of entities and show their state.
- type: entities - type: entities
# Title of the entities card # Title of the entities card
@ -327,7 +328,7 @@ async def test_lovelace_get_card(hass, hass_ws_client):
assert msg['id'] == 5 assert msg['id'] == 5
assert msg['type'] == TYPE_RESULT assert msg['type'] == TYPE_RESULT
assert msg['success'] assert msg['success']
assert msg['result'] == 'id: test\ntype: entities\n' assert msg['result'] == 'id: test\ntype: entities\ntitle: Test card\n'
async def test_lovelace_get_card_not_found(hass, hass_ws_client): async def test_lovelace_get_card_not_found(hass, hass_ws_client):
@ -494,3 +495,82 @@ async def test_lovelace_add_card_position(hass, hass_ws_client):
assert msg['id'] == 5 assert msg['id'] == 5
assert msg['type'] == TYPE_RESULT assert msg['type'] == TYPE_RESULT
assert msg['success'] assert msg['success']
async def test_lovelace_move_card_position(hass, hass_ws_client):
"""Test add_card command."""
await async_setup_component(hass, 'lovelace')
client = await hass_ws_client(hass)
yaml = YAML(typ='rt')
with patch('homeassistant.components.lovelace.load_yaml',
return_value=yaml.load(TEST_YAML_A)), \
patch('homeassistant.components.lovelace.save_yaml') \
as save_yaml_mock:
await client.send_json({
'id': 5,
'type': 'lovelace/config/card/move',
'card_id': 'test',
'new_position': 2,
})
msg = await client.receive_json()
result = save_yaml_mock.call_args_list[0][0][1]
assert result.mlget(['views', 1, 'cards', 2, 'title'],
list_ok=True) == 'Test card'
assert msg['id'] == 5
assert msg['type'] == TYPE_RESULT
assert msg['success']
async def test_lovelace_move_card_view(hass, hass_ws_client):
"""Test add_card command."""
await async_setup_component(hass, 'lovelace')
client = await hass_ws_client(hass)
yaml = YAML(typ='rt')
with patch('homeassistant.components.lovelace.load_yaml',
return_value=yaml.load(TEST_YAML_A)), \
patch('homeassistant.components.lovelace.save_yaml') \
as save_yaml_mock:
await client.send_json({
'id': 5,
'type': 'lovelace/config/card/move',
'card_id': 'test',
'new_view_id': 'example',
})
msg = await client.receive_json()
result = save_yaml_mock.call_args_list[0][0][1]
assert result.mlget(['views', 0, 'cards', 2, 'title'],
list_ok=True) == 'Test card'
assert msg['id'] == 5
assert msg['type'] == TYPE_RESULT
assert msg['success']
async def test_lovelace_move_card_view_position(hass, hass_ws_client):
"""Test add_card command."""
await async_setup_component(hass, 'lovelace')
client = await hass_ws_client(hass)
yaml = YAML(typ='rt')
with patch('homeassistant.components.lovelace.load_yaml',
return_value=yaml.load(TEST_YAML_A)), \
patch('homeassistant.components.lovelace.save_yaml') \
as save_yaml_mock:
await client.send_json({
'id': 5,
'type': 'lovelace/config/card/move',
'card_id': 'test',
'new_view_id': 'example',
'new_position': 1,
})
msg = await client.receive_json()
result = save_yaml_mock.call_args_list[0][0][1]
assert result.mlget(['views', 0, 'cards', 1, 'title'],
list_ok=True) == 'Test card'
assert msg['id'] == 5
assert msg['type'] == TYPE_RESULT
assert msg['success']