From b7896491e305f276a600f23a06919183711b13bb Mon Sep 17 00:00:00 2001 From: Bram Kragten <mail@bramkragten.nl> Date: Fri, 26 Oct 2018 12:56:14 +0200 Subject: [PATCH] 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 --- homeassistant/components/lovelace/__init__.py | 131 ++++++++++++++++-- tests/components/lovelace/test_init.py | 84 ++++++++++- 2 files changed, 205 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 141f3c98334..9102830e82f 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -27,6 +27,7 @@ WS_TYPE_GET_LOVELACE_UI = 'lovelace/config' WS_TYPE_GET_CARD = 'lovelace/config/card/get' WS_TYPE_UPDATE_CARD = 'lovelace/config/card/update' 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({ 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), }) +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): """Error writing the data.""" @@ -74,6 +82,10 @@ class UnsupportedYamlError(HomeAssistantError): """Unsupported YAML.""" +class DuplicateIdError(HomeAssistantError): + """Duplicate ID's.""" + + def save_yaml(fname: str, data: JSON_TYPE): """Save a YAML file.""" from ruamel.yaml import YAML @@ -134,19 +146,34 @@ def load_yaml(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.""" 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 + seen_card_ids = set() + seen_view_ids = set() index = 0 for view in config.get('views', []): - if 'id' not in view: + view_id = view.get('id') + if view_id is None: updated = True view.insert(0, 'id', index, 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', []): - if 'id' not in card: + card_id = card.get('id') + if card_id is None: updated = True card.insert(0, 'id', uuid.uuid4().hex, 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 if updated: 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) for view in config.get('views', []): for card in view.get('cards', []): - if card.get('id') != card_id: + if str(card.get('id')) != card_id: continue if data_format == FORMAT_YAML: return object_to_yaml(card) @@ -203,7 +230,7 @@ def update_card(fname: str, card_id: str, card_config: str, config = load_yaml(fname) for view in config.get('views', []): for card in view.get('cards', []): - if card.get('id') != card_id: + if str(card.get('id')) != card_id: continue if data_format == FORMAT_YAML: 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.""" config = load_yaml(fname) for view in config.get('views', []): - if view.get('id') != view_id: + if str(view.get('id')) != view_id: continue cards = view.get('cards', []) 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)) +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): """Set up the Lovelace commands.""" # 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, SCHEMA_ADD_CARD) + hass.components.websocket_api.async_register_command( + WS_TYPE_MOVE_CARD, websocket_lovelace_move_card, + SCHEMA_MOVE_CARD) + return True @@ -322,7 +402,7 @@ async def websocket_lovelace_update_card(hass, connection, msg): update_card, hass.config.path(LOVELACE_CONFIG_FILE), msg['card_id'], msg['card_config'], msg.get('format', FORMAT_YAML)) message = websocket_api.result_message( - msg['id'], True + msg['id'] ) except FileNotFoundError: 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.get('format', FORMAT_YAML)) message = websocket_api.result_message( - msg['id'], True + msg['id'] ) except FileNotFoundError: 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) 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) diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index d9465d7a752..5e486e295fe 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -3,6 +3,7 @@ import os import unittest from unittest.mock import patch from tempfile import mkdtemp +import pytest from ruamel.yaml import YAML 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, save_yaml, load_config, UnsupportedYamlError) -import pytest TEST_YAML_A = """\ title: My Awesome Home @@ -59,6 +59,7 @@ views: cards: - id: test type: entities + title: Test card # Entities card will take a list of entities and show their state. - type: entities # 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['type'] == TYPE_RESULT 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): @@ -494,3 +495,82 @@ async def test_lovelace_add_card_position(hass, hass_ws_client): assert msg['id'] == 5 assert msg['type'] == TYPE_RESULT 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']