mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-22 11:43:51 +01:00 
			
		
		
		
	Dashboard editor live validation (#540)
* Dashboard editor validation * Improve range detection * Lint
This commit is contained in:
		| @@ -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()) | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user