From 7bb77a93a740b4feebfe84ef70cfd16c7f68ac2b Mon Sep 17 00:00:00 2001 From: Ronny Eia <3652665+eiaro@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:27:13 +0200 Subject: [PATCH] Add CodeMirror-based script editor, verified against real hardware New Script section: loads the device's current Lua script (GET /api/script), edits it in a real code editor (CodeMirror 6 with Lua syntax highlighting via @codemirror/legacy-modes -- no dedicated @codemirror/lang-lua package exists, verified against the npm registry), and saves it back (POST /api/script) as raw text, not JSON, matching the device's endpoint. Not Blockly yet -- that's its own, much larger task; this is the raw-text step toward it. Verified end-to-end against a real ESP32-C6: load, edit, save hot-reloads on-device within one tick, and the change survives a power cycle. Co-Authored-By: Claude Sonnet 5 --- package-lock.json | 158 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 5 ++ src/device.ts | 23 +++++++ src/editor.ts | 28 ++++++++ src/main.ts | 54 +++++++++++++++- src/style.css | 20 +++++- 6 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 src/editor.ts diff --git a/package-lock.json b/package-lock.json index 39677ad..807ff27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,11 +7,106 @@ "": { "name": "kvida-sdk", "version": "0.0.1", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/legacy-modes": "^6.0.0", + "codemirror": "^6.0.0" + }, "devDependencies": { "typescript": "^5.6.0", "vite": "^6.0.0" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", + "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz", + "integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", + "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz", + "integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -454,6 +549,36 @@ "node": ">=18" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", @@ -811,6 +936,27 @@ "dev": true, "license": "MIT" }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1009,6 +1155,12 @@ "node": ">=0.10.0" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1114,6 +1266,12 @@ "optional": true } } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" } } } diff --git a/package.json b/package.json index 618390d..ba95d87 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,11 @@ "build": "tsc && vite build", "preview": "vite preview" }, + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/legacy-modes": "^6.0.0", + "codemirror": "^6.0.0" + }, "devDependencies": { "typescript": "^5.6.0", "vite": "^6.0.0" diff --git a/src/device.ts b/src/device.ts index 35f57b6..6332571 100644 --- a/src/device.ts +++ b/src/device.ts @@ -55,3 +55,26 @@ export async function saveMqttConfig(address: string, config: SaveMqttConfig): P throw new Error(data.error ?? `POST /api/mqtt failed: ${res.status}`); } } + +// Unlike the rest of this API, /api/script is raw Lua text in both +// directions, not JSON -- see kvida-os's transport README for why +// (scripts contain quotes/backslashes/newlines the device's hand-rolled, +// non-escaping JSON parser can't round-trip safely). +export async function getScript(address: string): Promise { + const res = await fetch(`${baseUrl(address)}/api/script`); + if (!res.ok) { + throw new Error(`GET /api/script failed: ${res.status}`); + } + return res.text(); +} + +export async function saveScript(address: string, script: string): Promise { + const res = await fetch(`${baseUrl(address)}/api/script`, { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: script, + }); + if (!res.ok) { + throw new Error(`POST /api/script failed: ${res.status}`); + } +} diff --git a/src/editor.ts b/src/editor.ts new file mode 100644 index 0000000..7e9adb1 --- /dev/null +++ b/src/editor.ts @@ -0,0 +1,28 @@ +// Thin wrapper around CodeMirror 6 for editing a device's Lua script. +// There's no dedicated @codemirror/lang-lua package (verified against +// the npm registry and @codemirror/legacy-modes' source) -- Lua +// highlighting comes from the legacy StreamLanguage-based mode instead. + +import { EditorView, basicSetup } from 'codemirror'; +import { StreamLanguage } from '@codemirror/language'; +import { lua } from '@codemirror/legacy-modes/mode/lua'; + +const luaLanguage = StreamLanguage.define(lua); + +export function createScriptEditor(parent: HTMLElement, initialDoc: string): EditorView { + return new EditorView({ + parent, + doc: initialDoc, + extensions: [basicSetup, luaLanguage], + }); +} + +export function getEditorText(view: EditorView): string { + return view.state.doc.toString(); +} + +export function setEditorText(view: EditorView, text: string): void { + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: text }, + }); +} diff --git a/src/main.ts b/src/main.ts index ae6e01d..dea3994 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,6 @@ -import { getMqttConfig, getStatus, saveMqttConfig } from './device'; +import { getMqttConfig, getScript, getStatus, saveMqttConfig, saveScript } from './device'; +import { createScriptEditor, getEditorText, setEditorText } from './editor'; +import type { EditorView } from 'codemirror'; import './style.css'; const STORAGE_KEY = 'kvida-sdk:device-address'; @@ -30,6 +32,14 @@ app.innerHTML = `

+ + `; @@ -41,9 +51,23 @@ const hostInput = document.querySelector('#host')!; const portInput = document.querySelector('#port')!; const usernameInput = document.querySelector('#username')!; const passwordInput = document.querySelector('#password')!; +const scriptSection = document.querySelector('#script-section')!; +const scriptEditorContainer = document.querySelector('#script-editor')!; +const scriptStatus = document.querySelector('#script-status')!; addressInput.value = localStorage.getItem(STORAGE_KEY) ?? ''; +let scriptEditor: EditorView | null = null; + +async function loadScriptIntoEditor(address: string) { + const script = await getScript(address); + if (!scriptEditor) { + scriptEditor = createScriptEditor(scriptEditorContainer, script); + } else { + setEditorText(scriptEditor, script); + } +} + async function connect() { const address = addressInput.value.trim(); if (!address) { @@ -53,6 +77,7 @@ async function connect() { statusLine.textContent = 'Connecting...'; mqttForm.hidden = true; + scriptSection.hidden = true; try { const status = await getStatus(address); statusLine.textContent = @@ -65,6 +90,10 @@ async function connect() { usernameInput.value = mqtt.username; passwordInput.value = ''; mqttForm.hidden = false; + + scriptStatus.textContent = ''; + await loadScriptIntoEditor(address); + scriptSection.hidden = false; } catch (err) { statusLine.textContent = `Failed to reach device: ${err}`; } @@ -88,6 +117,29 @@ mqttForm.addEventListener('submit', async (e) => { } }); +document.querySelector('#script-load')!.addEventListener('click', async () => { + scriptStatus.textContent = 'Loading...'; + try { + await loadScriptIntoEditor(addressInput.value.trim()); + scriptStatus.textContent = 'Loaded from device.'; + } catch (err) { + scriptStatus.textContent = `Failed to load: ${err}`; + } +}); + +document.querySelector('#script-save')!.addEventListener('click', async () => { + if (!scriptEditor) { + return; + } + scriptStatus.textContent = 'Saving...'; + try { + await saveScript(addressInput.value.trim(), getEditorText(scriptEditor)); + scriptStatus.textContent = 'Saved. Device hot-reloaded the script.'; + } catch (err) { + scriptStatus.textContent = `Failed to save: ${err}`; + } +}); + if (addressInput.value) { connect(); } diff --git a/src/style.css b/src/style.css index 00b3b1a..c600215 100644 --- a/src/style.css +++ b/src/style.css @@ -1,10 +1,28 @@ body { font-family: sans-serif; - max-width: 360px; + max-width: 720px; margin: 2em auto; padding: 0 1em; } +#mqtt-form, +#status-line { + max-width: 360px; +} + +#script-editor { + border: 1px solid #ccc; + margin-top: 0.5em; +} + +#script-editor .cm-editor { + height: 400px; +} + +#script-editor .cm-scroller { + overflow: auto; +} + label { display: block; margin-top: 1em;