From a1a74488687b02ef2ac0ab57f6058d4aded402f3 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Sat, 11 May 2019 11:41:09 +0200 Subject: [PATCH] Dashboard editor live validation (#540) * Dashboard editor validation * Improve range detection * Lint --- esphome/__main__.py | 5 +- esphome/config.py | 12 ++-- esphome/config_helpers.py | 4 +- esphome/core.py | 1 + esphome/dashboard/dashboard.py | 12 +++- esphome/dashboard/static/esphome.js | 96 +++++++++++++++++++++++++++++ esphome/vscode.py | 23 ++++--- 7 files changed, 134 insertions(+), 19 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 4b01b5425f..c06b570cb5 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -245,7 +245,7 @@ def command_vscode(args): from esphome import vscode CORE.config_path = args.configuration - vscode.read_config() + vscode.read_config(args) def command_compile(args, config): @@ -423,7 +423,8 @@ def parse_args(argv): dashboard.add_argument("--socket", help="Make the dashboard serve under a unix socket", type=str) - subparsers.add_parser('vscode', help=argparse.SUPPRESS) + vscode = subparsers.add_parser('vscode', help=argparse.SUPPRESS) + vscode.add_argument('--ace', action='store_true') return parser.parse_args(argv[1:]) diff --git a/esphome/config.py b/esphome/config.py index 901a78ea9a..671253b31e 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -233,15 +233,19 @@ class Config(OrderedDict): return err return None - def get_deepest_value_for_path(self, path): - # type: (ConfigPath) -> ConfigType + def get_deepest_document_range_for_path(self, path): + # type: (ConfigPath) -> Optional[ESPHomeDataBase] data = self + doc_range = None for item_index in path: try: data = data[item_index] except (KeyError, IndexError, TypeError): - return data - return data + return doc_range + if isinstance(data, ESPHomeDataBase) and data.esp_range is not None: + doc_range = data.esp_range + + return doc_range def get_nested_item(self, path): # type: (ConfigPath) -> ConfigType diff --git a/esphome/config_helpers.py b/esphome/config_helpers.py index c235371db1..ddad36f8a8 100644 --- a/esphome/config_helpers.py +++ b/esphome/config_helpers.py @@ -2,6 +2,7 @@ from __future__ import print_function import codecs import json +import os from esphome.core import CORE, EsphomeError from esphome.py_compat import safe_input @@ -9,7 +10,8 @@ from esphome.py_compat import safe_input def read_config_file(path): # type: (basestring) -> unicode - if CORE.vscode: + if CORE.vscode and (not CORE.ace or + os.path.abspath(path) == os.path.abspath(CORE.config_path)): print(json.dumps({ 'type': 'read_file', 'path': path, diff --git a/esphome/core.py b/esphome/core.py index 464782368e..f2bc4fdf2d 100644 --- a/esphome/core.py +++ b/esphome/core.py @@ -467,6 +467,7 @@ class EsphomeCore(object): self.dashboard = False # True if command is run from vscode api self.vscode = False + self.ace = False # The name of the node self.name = None # type: str # The relative path to the configuration YAML diff --git a/esphome/dashboard/dashboard.py b/esphome/dashboard/dashboard.py index 66ec1e1e54..91370a8194 100644 --- a/esphome/dashboard/dashboard.py +++ b/esphome/dashboard/dashboard.py @@ -256,12 +256,12 @@ class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler): self.write_message({'event': 'exit', 'code': returncode}) def on_close(self): - # Shutdown proc on WS close - self._is_closed = True # Check if proc exists (if 'start' has been run) if self.is_process_active: _LOGGER.debug("Terminating process") self._proc.proc.terminate() + # Shutdown proc on WS close + self._is_closed = True def build_command(self, json_message): raise NotImplementedError @@ -310,6 +310,11 @@ class EsphomeVscodeHandler(EsphomeCommandWebSocket): return ["esphome", "--dashboard", "-q", 'dummy', "vscode"] +class EsphomeAceEditorHandler(EsphomeCommandWebSocket): + def build_command(self, json_message): + return ["esphome", "--dashboard", "-q", settings.config_dir, "vscode", "--ace"] + + class SerialPortRequestHandler(BaseHandler): @authenticated def get(self): @@ -678,6 +683,7 @@ def make_app(debug=False): (rel + "clean-mqtt", EsphomeCleanMqttHandler), (rel + "clean", EsphomeCleanHandler), (rel + "vscode", EsphomeVscodeHandler), + (rel + "ace", EsphomeAceEditorHandler), (rel + "edit", EditRequestHandler), (rel + "download.bin", DownloadBinaryRequestHandler), (rel + "serial-ports", SerialPortRequestHandler), @@ -723,7 +729,7 @@ def start_web_server(args): webbrowser.open('localhost:{}'.format(args.port)) - if settings.status_use_ping: + if not settings.status_use_ping: status_thread = PingStatusThread() else: status_thread = MDNSStatusThread() diff --git a/esphome/dashboard/static/esphome.js b/esphome/dashboard/static/esphome.js index 5ec29ba0dd..5a4a89ef5f 100644 --- a/esphome/dashboard/static/esphome.js +++ b/esphome/dashboard/static/esphome.js @@ -550,10 +550,75 @@ const editModalElem = document.getElementById("modal-editor"); const editorElem = editModalElem.querySelector("#editor"); const editor = ace.edit(editorElem); let activeEditorConfig = null; +let aceWs = null; +let aceValidationScheduled = false; +let aceValidationRunning = false; +const startAceWebsocket = () => { + aceWs = new WebSocket(`${wsUrl}ace`); + aceWs.addEventListener('message', (event) => { + const raw = JSON.parse(event.data); + if (raw.event === "line") { + const msg = JSON.parse(raw.data); + if (msg.type === "result") { + console.log(msg); + const arr = []; + + for (const v of msg.validation_errors) { + let o = { + text: v.message, + type: 'error', + row: 0, + column: 0 + }; + if (v.range != null) { + o.row = v.range.start_line; + o.column = v.range.start_col; + } + arr.push(o); + } + for (const v of msg.yaml_errors) { + arr.push({ + text: v.message, + type: 'error', + row: 0, + column: 0 + }); + } + + editor.session.setAnnotations(arr); + + aceValidationRunning = false; + } else if (msg.type === "read_file") { + sendAceStdin({ + type: 'file_response', + content: editor.getValue() + }); + } + } + }); + aceWs.addEventListener('open', () => { + const msg = JSON.stringify({type: 'spawn'}); + aceWs.send(msg); + }); + aceWs.addEventListener('close', () => { + aceWs = null; + setTimeout(startAceWebsocket, 5000) + }); +}; +const sendAceStdin = (data) => { + let send = JSON.stringify({ + type: 'stdin', + data: JSON.stringify(data)+'\n', + }); + aceWs.send(send); +}; +startAceWebsocket(); + editor.setTheme("ace/theme/dreamweaver"); editor.session.setMode("ace/mode/yaml"); editor.session.setOption('useSoftTabs', true); editor.session.setOption('tabSize', 2); +editor.session.setOption('useWorker', false); const saveButton = editModalElem.querySelector(".save-button"); const saveValidateButton = editModalElem.querySelector(".save-validate-button"); @@ -569,6 +634,19 @@ const saveEditor = () => { }); }; +const debounce = (func, wait) => { + let timeout; + return function() { + let context = this, args = arguments; + let later = function() { + timeout = null; + func.apply(context, args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +}; + editor.commands.addCommand({ name: 'saveCommand', bindKey: {win: 'Ctrl-S', mac: 'Command-S'}, @@ -576,6 +654,24 @@ editor.commands.addCommand({ readOnly: false }); +editor.session.on('change', debounce(() => { + aceValidationScheduled = true; +}, 250)); + +setInterval(() => { + if (!aceValidationScheduled || aceValidationRunning) + return; + if (aceWs == null) + return; + + sendAceStdin({ + type: 'validate', + file: activeEditorConfig + }); + aceValidationRunning = true; + aceValidationScheduled = false; +}, 100); + saveButton.addEventListener('click', saveEditor); saveValidateButton.addEventListener('click', saveEditor); diff --git a/esphome/vscode.py b/esphome/vscode.py index ebbacdf6c7..f0fa4ef52a 100644 --- a/esphome/vscode.py +++ b/esphome/vscode.py @@ -1,19 +1,16 @@ from __future__ import print_function import json +import os from esphome.config import load_config, _format_vol_invalid from esphome.core import CORE from esphome.py_compat import text_type, safe_input -from esphome.yaml_util import ESPHomeDataBase def _get_invalid_range(res, invalid): # type: (Config, vol.Invalid) -> Optional[DocumentRange] - obj = res.get_deepest_value_for_path(invalid.path) - if isinstance(obj, ESPHomeDataBase) and obj.esp_range is not None: - return obj.esp_range - return None + return res.get_deepest_document_range_for_path(invalid.path) def _dump_range(range): @@ -53,13 +50,18 @@ class VSCodeResult(object): }) -def read_config(): +def read_config(args): while True: CORE.reset() data = json.loads(safe_input()) assert data['type'] == 'validate' CORE.vscode = True - CORE.config_path = data['file'] + CORE.ace = args.ace + f = data['file'] + if CORE.ace: + CORE.config_path = os.path.join(args.configuration, f) + else: + CORE.config_path = data['file'] vs = VSCodeResult() try: res = load_config() @@ -67,6 +69,9 @@ def read_config(): vs.add_yaml_error(text_type(err)) else: for err in res.errors: - range_ = _get_invalid_range(res, err) - vs.add_validation_error(range_, _format_vol_invalid(err, res)) + try: + range_ = _get_invalid_range(res, err) + vs.add_validation_error(range_, _format_vol_invalid(err, res)) + except Exception: # pylint: disable=broad-except + continue print(vs.dump())