From 4163889c6bf165fd94aa9e5c43883fa6bfec432a Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 1 Nov 2018 09:44:38 +0100 Subject: [PATCH] Add view commands to Lovelace (#18063) * Add get and update view command * Add add view command * Add move view command * Add delete command * lint --- homeassistant/components/lovelace/__init__.py | 190 +++++++++++++++++- tests/components/lovelace/test_init.py | 181 +++++++++++++++++ 2 files changed, 369 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index d21dc3867d8..540fb601a90 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -21,14 +21,20 @@ FORMAT_JSON = 'json' OLD_WS_TYPE_GET_LOVELACE_UI = 'frontend/lovelace_config' WS_TYPE_GET_LOVELACE_UI = 'lovelace/config' - WS_TYPE_MIGRATE_CONFIG = 'lovelace/config/migrate' + 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' WS_TYPE_DELETE_CARD = 'lovelace/config/card/delete' +WS_TYPE_GET_VIEW = 'lovelace/config/view/get' +WS_TYPE_UPDATE_VIEW = 'lovelace/config/view/update' +WS_TYPE_ADD_VIEW = 'lovelace/config/view/add' +WS_TYPE_MOVE_VIEW = 'lovelace/config/view/move' +WS_TYPE_DELETE_VIEW = 'lovelace/config/view/delete' + 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), @@ -74,6 +80,40 @@ SCHEMA_DELETE_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('card_id'): str, }) +SCHEMA_GET_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_GET_VIEW, + vol.Required('view_id'): str, + vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, + FORMAT_YAML), +}) + +SCHEMA_UPDATE_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_UPDATE_VIEW, + vol.Required('view_id'): str, + vol.Required('view_config'): vol.Any(str, Dict), + vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, + FORMAT_YAML), +}) + +SCHEMA_ADD_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_ADD_VIEW, + vol.Required('view_config'): vol.Any(str, Dict), + vol.Optional('position'): int, + vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, + FORMAT_YAML), +}) + +SCHEMA_MOVE_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_MOVE_VIEW, + vol.Required('view_id'): str, + vol.Required('new_position'): int, +}) + +SCHEMA_DELETE_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_DELETE_VIEW, + vol.Required('view_id'): str, +}) + class CardNotFoundError(HomeAssistantError): """Card not found in data.""" @@ -156,6 +196,7 @@ def update_card(fname: str, card_id: str, card_config: str, continue if data_format == FORMAT_YAML: card_config = yaml.yaml_to_object(card_config) + card.clear() card.update(card_config) yaml.save_yaml(fname, config) return @@ -234,7 +275,7 @@ def move_card_view(fname: str, card_id: str, view_id: str, yaml.save_yaml(fname, config) -def delete_card(fname: str, card_id: str, position: int = None) -> None: +def delete_card(fname: str, card_id: str) -> None: """Delete a card from view.""" config = yaml.load_yaml(fname, True) for view in config.get('views', []): @@ -250,6 +291,85 @@ def delete_card(fname: str, card_id: str, position: int = None) -> None: "Card with ID: {} was not found in {}.".format(card_id, fname)) +def get_view(fname: str, view_id: str, data_format: str = FORMAT_YAML) -> None: + """Get view without it's cards.""" + round_trip = data_format == FORMAT_YAML + config = yaml.load_yaml(fname, round_trip) + for view in config.get('views', []): + if str(view.get('id', '')) != view_id: + continue + del view['cards'] + if data_format == FORMAT_YAML: + return yaml.object_to_yaml(view) + return view + + raise ViewNotFoundError( + "View with ID: {} was not found in {}.".format(view_id, fname)) + + +def update_view(fname: str, view_id: str, view_config, data_format: + str = FORMAT_YAML) -> None: + """Update view.""" + config = yaml.load_yaml(fname, True) + for view in config.get('views', []): + if str(view.get('id', '')) != view_id: + continue + if data_format == FORMAT_YAML: + view_config = yaml.yaml_to_object(view_config) + view_config['cards'] = view.get('cards', []) + view.clear() + view.update(view_config) + yaml.save_yaml(fname, config) + return + + raise ViewNotFoundError( + "View with ID: {} was not found in {}.".format(view_id, fname)) + + +def add_view(fname: str, view_config: str, + position: int = None, data_format: str = FORMAT_YAML) -> None: + """Add a view.""" + config = yaml.load_yaml(fname, True) + views = config.get('views', []) + if data_format == FORMAT_YAML: + view_config = yaml.yaml_to_object(view_config) + if position is None: + views.append(view_config) + else: + views.insert(position, view_config) + yaml.save_yaml(fname, config) + + +def move_view(fname: str, view_id: str, position: int) -> None: + """Move a view to a different position.""" + config = yaml.load_yaml(fname, True) + views = config.get('views', []) + for view in views: + if str(view.get('id', '')) != view_id: + continue + views.insert(position, views.pop(views.index(view))) + yaml.save_yaml(fname, config) + return + + raise ViewNotFoundError( + "View with ID: {} was not found in {}.".format(view_id, fname)) + + +def delete_view(fname: str, view_id: str) -> None: + """Delete a view.""" + config = yaml.load_yaml(fname, True) + views = config.get('views', []) + for view in views: + if str(view.get('id', '')) != view_id: + continue + views.pop(views.index(view)) + yaml.save_yaml(fname, config) + return + + raise ViewNotFoundError( + "View with ID: {} was not found in {}.".format(view_id, fname)) + + async def async_setup(hass, config): """Set up the Lovelace commands.""" # Backwards compat. Added in 0.80. Remove after 0.85 @@ -285,6 +405,26 @@ async def async_setup(hass, config): WS_TYPE_DELETE_CARD, websocket_lovelace_delete_card, SCHEMA_DELETE_CARD) + hass.components.websocket_api.async_register_command( + WS_TYPE_GET_VIEW, websocket_lovelace_get_view, + SCHEMA_GET_VIEW) + + hass.components.websocket_api.async_register_command( + WS_TYPE_UPDATE_VIEW, websocket_lovelace_update_view, + SCHEMA_UPDATE_VIEW) + + hass.components.websocket_api.async_register_command( + WS_TYPE_ADD_VIEW, websocket_lovelace_add_view, + SCHEMA_ADD_VIEW) + + hass.components.websocket_api.async_register_command( + WS_TYPE_MOVE_VIEW, websocket_lovelace_move_view, + SCHEMA_MOVE_VIEW) + + hass.components.websocket_api.async_register_command( + WS_TYPE_DELETE_VIEW, websocket_lovelace_delete_view, + SCHEMA_DELETE_VIEW) + return True @@ -385,3 +525,49 @@ async def websocket_lovelace_delete_card(hass, connection, msg): return await hass.async_add_executor_job( delete_card, hass.config.path(LOVELACE_CONFIG_FILE), msg['card_id']) + + +@websocket_api.async_response +@handle_yaml_errors +async def websocket_lovelace_get_view(hass, connection, msg): + """Send lovelace view config over websocket config.""" + return await hass.async_add_executor_job( + get_view, hass.config.path(LOVELACE_CONFIG_FILE), msg['view_id'], + msg.get('format', FORMAT_YAML)) + + +@websocket_api.async_response +@handle_yaml_errors +async def websocket_lovelace_update_view(hass, connection, msg): + """Receive lovelace card config over websocket and save.""" + return await hass.async_add_executor_job( + update_view, hass.config.path(LOVELACE_CONFIG_FILE), + msg['view_id'], msg['view_config'], msg.get('format', FORMAT_YAML)) + + +@websocket_api.async_response +@handle_yaml_errors +async def websocket_lovelace_add_view(hass, connection, msg): + """Add new view over websocket and save.""" + return await hass.async_add_executor_job( + add_view, hass.config.path(LOVELACE_CONFIG_FILE), + msg['view_config'], msg.get('position'), + msg.get('format', FORMAT_YAML)) + + +@websocket_api.async_response +@handle_yaml_errors +async def websocket_lovelace_move_view(hass, connection, msg): + """Move view to different position over websocket and save.""" + return await hass.async_add_executor_job( + move_view, hass.config.path(LOVELACE_CONFIG_FILE), + msg['view_id'], msg['new_position']) + + +@websocket_api.async_response +@handle_yaml_errors +async def websocket_lovelace_delete_view(hass, connection, msg): + """Delete card from lovelace over websocket and save.""" + return await hass.async_add_executor_job( + delete_view, hass.config.path(LOVELACE_CONFIG_FILE), + msg['view_id']) diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index 690c4976565..e296d14c6f8 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -565,3 +565,184 @@ async def test_lovelace_delete_card(hass, hass_ws_client): assert msg['id'] == 5 assert msg['type'] == TYPE_RESULT assert msg['success'] + + +async def test_lovelace_get_view(hass, hass_ws_client): + """Test get_view command.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.util.ruamel_yaml.load_yaml', + return_value=yaml.load(TEST_YAML_A)): + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/view/get', + 'view_id': 'example', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + assert "".join(msg['result'].split()) == "".join('title: Example\n # \ + Optional unique id for direct\ + access /lovelace/${id}\nid: example\n # Optional\ + background (overwrites the global background).\n\ + background: radial-gradient(crimson, skyblue)\n\ + # Each view can have a different theme applied.\n\ + theme: dark-mode\n'.split()) + + +async def test_lovelace_get_view_not_found(hass, hass_ws_client): + """Test get_card command cannot find card.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.util.ruamel_yaml.load_yaml', + return_value=yaml.load(TEST_YAML_A)): + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/view/get', + 'view_id': 'not_found', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] is False + assert msg['error']['code'] == 'view_not_found' + + +async def test_lovelace_update_view(hass, hass_ws_client): + """Test update_view command.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + origyaml = yaml.load(TEST_YAML_A) + + with patch('homeassistant.util.ruamel_yaml.load_yaml', + return_value=origyaml), \ + patch('homeassistant.util.ruamel_yaml.save_yaml') \ + as save_yaml_mock: + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/view/update', + 'view_id': 'example', + 'view_config': 'id: example2\ntitle: New title\n', + }) + msg = await client.receive_json() + + result = save_yaml_mock.call_args_list[0][0][1] + orig_view = origyaml.mlget(['views', 0], list_ok=True) + new_view = result.mlget(['views', 0], list_ok=True) + assert new_view['title'] == 'New title' + assert new_view['cards'] == orig_view['cards'] + assert 'theme' not in new_view + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + + +async def test_lovelace_add_view(hass, hass_ws_client): + """Test add_view command.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.util.ruamel_yaml.load_yaml', + return_value=yaml.load(TEST_YAML_A)), \ + patch('homeassistant.util.ruamel_yaml.save_yaml') \ + as save_yaml_mock: + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/view/add', + 'view_config': 'id: test\ntitle: added\n', + }) + msg = await client.receive_json() + + result = save_yaml_mock.call_args_list[0][0][1] + assert result.mlget(['views', 2, 'title'], + list_ok=True) == 'added' + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + + +async def test_lovelace_add_view_position(hass, hass_ws_client): + """Test add_view command with position.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.util.ruamel_yaml.load_yaml', + return_value=yaml.load(TEST_YAML_A)), \ + patch('homeassistant.util.ruamel_yaml.save_yaml') \ + as save_yaml_mock: + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/view/add', + 'position': 0, + 'view_config': 'id: test\ntitle: added\n', + }) + msg = await client.receive_json() + + result = save_yaml_mock.call_args_list[0][0][1] + assert result.mlget(['views', 0, 'title'], + list_ok=True) == 'added' + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + + +async def test_lovelace_move_view_position(hass, hass_ws_client): + """Test move_view command.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.util.ruamel_yaml.load_yaml', + return_value=yaml.load(TEST_YAML_A)), \ + patch('homeassistant.util.ruamel_yaml.save_yaml') \ + as save_yaml_mock: + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/view/move', + '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', 1, 'title'], + list_ok=True) == 'Example' + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + + +async def test_lovelace_delete_view(hass, hass_ws_client): + """Test delete_card command.""" + await async_setup_component(hass, 'lovelace') + client = await hass_ws_client(hass) + yaml = YAML(typ='rt') + + with patch('homeassistant.util.ruamel_yaml.load_yaml', + return_value=yaml.load(TEST_YAML_A)), \ + patch('homeassistant.util.ruamel_yaml.save_yaml') \ + as save_yaml_mock: + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config/view/delete', + 'view_id': 'example', + }) + msg = await client.receive_json() + + result = save_yaml_mock.call_args_list[0][0][1] + views = result.get('views', []) + assert len(views) == 1 + assert views[0]['title'] == 'Second view' + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success']