Dashboard editor live validation (#540)

* Dashboard editor validation

* Improve range detection

* Lint
This commit is contained in:
Otto Winter 2019-05-11 11:41:09 +02:00 committed by GitHub
parent e373620393
commit a1a7448868
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 134 additions and 19 deletions

View file

@ -245,7 +245,7 @@ def command_vscode(args):
from esphome import vscode from esphome import vscode
CORE.config_path = args.configuration CORE.config_path = args.configuration
vscode.read_config() vscode.read_config(args)
def command_compile(args, config): def command_compile(args, config):
@ -423,7 +423,8 @@ def parse_args(argv):
dashboard.add_argument("--socket", dashboard.add_argument("--socket",
help="Make the dashboard serve under a unix socket", type=str) 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:]) return parser.parse_args(argv[1:])

View file

@ -233,15 +233,19 @@ class Config(OrderedDict):
return err return err
return None return None
def get_deepest_value_for_path(self, path): def get_deepest_document_range_for_path(self, path):
# type: (ConfigPath) -> ConfigType # type: (ConfigPath) -> Optional[ESPHomeDataBase]
data = self data = self
doc_range = None
for item_index in path: for item_index in path:
try: try:
data = data[item_index] data = data[item_index]
except (KeyError, IndexError, TypeError): except (KeyError, IndexError, TypeError):
return data return doc_range
return data 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): def get_nested_item(self, path):
# type: (ConfigPath) -> ConfigType # type: (ConfigPath) -> ConfigType

View file

@ -2,6 +2,7 @@ from __future__ import print_function
import codecs import codecs
import json import json
import os
from esphome.core import CORE, EsphomeError from esphome.core import CORE, EsphomeError
from esphome.py_compat import safe_input from esphome.py_compat import safe_input
@ -9,7 +10,8 @@ from esphome.py_compat import safe_input
def read_config_file(path): def read_config_file(path):
# type: (basestring) -> unicode # 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({ print(json.dumps({
'type': 'read_file', 'type': 'read_file',
'path': path, 'path': path,

View file

@ -467,6 +467,7 @@ class EsphomeCore(object):
self.dashboard = False self.dashboard = False
# True if command is run from vscode api # True if command is run from vscode api
self.vscode = False self.vscode = False
self.ace = False
# The name of the node # The name of the node
self.name = None # type: str self.name = None # type: str
# The relative path to the configuration YAML # The relative path to the configuration YAML

View file

@ -256,12 +256,12 @@ class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler):
self.write_message({'event': 'exit', 'code': returncode}) self.write_message({'event': 'exit', 'code': returncode})
def on_close(self): def on_close(self):
# Shutdown proc on WS close
self._is_closed = True
# Check if proc exists (if 'start' has been run) # Check if proc exists (if 'start' has been run)
if self.is_process_active: if self.is_process_active:
_LOGGER.debug("Terminating process") _LOGGER.debug("Terminating process")
self._proc.proc.terminate() self._proc.proc.terminate()
# Shutdown proc on WS close
self._is_closed = True
def build_command(self, json_message): def build_command(self, json_message):
raise NotImplementedError raise NotImplementedError
@ -310,6 +310,11 @@ class EsphomeVscodeHandler(EsphomeCommandWebSocket):
return ["esphome", "--dashboard", "-q", 'dummy', "vscode"] 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): class SerialPortRequestHandler(BaseHandler):
@authenticated @authenticated
def get(self): def get(self):
@ -678,6 +683,7 @@ def make_app(debug=False):
(rel + "clean-mqtt", EsphomeCleanMqttHandler), (rel + "clean-mqtt", EsphomeCleanMqttHandler),
(rel + "clean", EsphomeCleanHandler), (rel + "clean", EsphomeCleanHandler),
(rel + "vscode", EsphomeVscodeHandler), (rel + "vscode", EsphomeVscodeHandler),
(rel + "ace", EsphomeAceEditorHandler),
(rel + "edit", EditRequestHandler), (rel + "edit", EditRequestHandler),
(rel + "download.bin", DownloadBinaryRequestHandler), (rel + "download.bin", DownloadBinaryRequestHandler),
(rel + "serial-ports", SerialPortRequestHandler), (rel + "serial-ports", SerialPortRequestHandler),
@ -723,7 +729,7 @@ def start_web_server(args):
webbrowser.open('localhost:{}'.format(args.port)) webbrowser.open('localhost:{}'.format(args.port))
if settings.status_use_ping: if not settings.status_use_ping:
status_thread = PingStatusThread() status_thread = PingStatusThread()
else: else:
status_thread = MDNSStatusThread() status_thread = MDNSStatusThread()

View file

@ -550,10 +550,75 @@ const editModalElem = document.getElementById("modal-editor");
const editorElem = editModalElem.querySelector("#editor"); const editorElem = editModalElem.querySelector("#editor");
const editor = ace.edit(editorElem); const editor = ace.edit(editorElem);
let activeEditorConfig = null; 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.setTheme("ace/theme/dreamweaver");
editor.session.setMode("ace/mode/yaml"); editor.session.setMode("ace/mode/yaml");
editor.session.setOption('useSoftTabs', true); editor.session.setOption('useSoftTabs', true);
editor.session.setOption('tabSize', 2); editor.session.setOption('tabSize', 2);
editor.session.setOption('useWorker', false);
const saveButton = editModalElem.querySelector(".save-button"); const saveButton = editModalElem.querySelector(".save-button");
const saveValidateButton = editModalElem.querySelector(".save-validate-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({ editor.commands.addCommand({
name: 'saveCommand', name: 'saveCommand',
bindKey: {win: 'Ctrl-S', mac: 'Command-S'}, bindKey: {win: 'Ctrl-S', mac: 'Command-S'},
@ -576,6 +654,24 @@ editor.commands.addCommand({
readOnly: false 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); saveButton.addEventListener('click', saveEditor);
saveValidateButton.addEventListener('click', saveEditor); saveValidateButton.addEventListener('click', saveEditor);

View file

@ -1,19 +1,16 @@
from __future__ import print_function from __future__ import print_function
import json import json
import os
from esphome.config import load_config, _format_vol_invalid from esphome.config import load_config, _format_vol_invalid
from esphome.core import CORE from esphome.core import CORE
from esphome.py_compat import text_type, safe_input from esphome.py_compat import text_type, safe_input
from esphome.yaml_util import ESPHomeDataBase
def _get_invalid_range(res, invalid): def _get_invalid_range(res, invalid):
# type: (Config, vol.Invalid) -> Optional[DocumentRange] # type: (Config, vol.Invalid) -> Optional[DocumentRange]
obj = res.get_deepest_value_for_path(invalid.path) return res.get_deepest_document_range_for_path(invalid.path)
if isinstance(obj, ESPHomeDataBase) and obj.esp_range is not None:
return obj.esp_range
return None
def _dump_range(range): def _dump_range(range):
@ -53,12 +50,17 @@ class VSCodeResult(object):
}) })
def read_config(): def read_config(args):
while True: while True:
CORE.reset() CORE.reset()
data = json.loads(safe_input()) data = json.loads(safe_input())
assert data['type'] == 'validate' assert data['type'] == 'validate'
CORE.vscode = True CORE.vscode = True
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'] CORE.config_path = data['file']
vs = VSCodeResult() vs = VSCodeResult()
try: try:
@ -67,6 +69,9 @@ def read_config():
vs.add_yaml_error(text_type(err)) vs.add_yaml_error(text_type(err))
else: else:
for err in res.errors: for err in res.errors:
try:
range_ = _get_invalid_range(res, err) range_ = _get_invalid_range(res, err)
vs.add_validation_error(range_, _format_vol_invalid(err, res)) vs.add_validation_error(range_, _format_vol_invalid(err, res))
except Exception: # pylint: disable=broad-except
continue
print(vs.dump()) print(vs.dump())