mirror of
https://github.com/esphome/esphome.git
synced 2024-11-28 09:44:12 +01:00
Dashboard editor live validation (#540)
* Dashboard editor validation * Improve range detection * Lint
This commit is contained in:
parent
e373620393
commit
a1a7448868
7 changed files with 134 additions and 19 deletions
|
@ -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:])
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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())
|
||||||
|
|
Loading…
Reference in a new issue