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 <noreply@anthropic.com>
This commit is contained in:
Ronny Eia
2026-07-12 10:27:13 +02:00
parent 6468dddc05
commit 7bb77a93a7
6 changed files with 286 additions and 2 deletions

158
package-lock.json generated
View File

@@ -7,11 +7,106 @@
"": { "": {
"name": "kvida-sdk", "name": "kvida-sdk",
"version": "0.0.1", "version": "0.0.1",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/legacy-modes": "^6.0.0",
"codemirror": "^6.0.0"
},
"devDependencies": { "devDependencies": {
"typescript": "^5.6.0", "typescript": "^5.6.0",
"vite": "^6.0.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": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -454,6 +549,36 @@
"node": ">=18" "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": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.2", "version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
@@ -811,6 +936,27 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -1009,6 +1155,12 @@
"node": ">=0.10.0" "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": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1114,6 +1266,12 @@
"optional": true "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"
} }
} }
} }

View File

@@ -8,6 +8,11 @@
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/legacy-modes": "^6.0.0",
"codemirror": "^6.0.0"
},
"devDependencies": { "devDependencies": {
"typescript": "^5.6.0", "typescript": "^5.6.0",
"vite": "^6.0.0" "vite": "^6.0.0"

View File

@@ -55,3 +55,26 @@ export async function saveMqttConfig(address: string, config: SaveMqttConfig): P
throw new Error(data.error ?? `POST /api/mqtt failed: ${res.status}`); 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<string> {
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<void> {
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}`);
}
}

28
src/editor.ts Normal file
View File

@@ -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 },
});
}

View File

@@ -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'; import './style.css';
const STORAGE_KEY = 'kvida-sdk:device-address'; const STORAGE_KEY = 'kvida-sdk:device-address';
@@ -30,6 +32,14 @@ app.innerHTML = `
<button type="submit">Save</button> <button type="submit">Save</button>
<p id="mqtt-status"></p> <p id="mqtt-status"></p>
</form> </form>
<section id="script-section" hidden>
<h2>Script</h2>
<div id="script-editor"></div>
<button id="script-load">Reload from device</button>
<button id="script-save">Save (hot-reloads on device)</button>
<p id="script-status"></p>
</section>
</main> </main>
`; `;
@@ -41,9 +51,23 @@ const hostInput = document.querySelector<HTMLInputElement>('#host')!;
const portInput = document.querySelector<HTMLInputElement>('#port')!; const portInput = document.querySelector<HTMLInputElement>('#port')!;
const usernameInput = document.querySelector<HTMLInputElement>('#username')!; const usernameInput = document.querySelector<HTMLInputElement>('#username')!;
const passwordInput = document.querySelector<HTMLInputElement>('#password')!; const passwordInput = document.querySelector<HTMLInputElement>('#password')!;
const scriptSection = document.querySelector<HTMLElement>('#script-section')!;
const scriptEditorContainer = document.querySelector<HTMLDivElement>('#script-editor')!;
const scriptStatus = document.querySelector<HTMLParagraphElement>('#script-status')!;
addressInput.value = localStorage.getItem(STORAGE_KEY) ?? ''; 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() { async function connect() {
const address = addressInput.value.trim(); const address = addressInput.value.trim();
if (!address) { if (!address) {
@@ -53,6 +77,7 @@ async function connect() {
statusLine.textContent = 'Connecting...'; statusLine.textContent = 'Connecting...';
mqttForm.hidden = true; mqttForm.hidden = true;
scriptSection.hidden = true;
try { try {
const status = await getStatus(address); const status = await getStatus(address);
statusLine.textContent = statusLine.textContent =
@@ -65,6 +90,10 @@ async function connect() {
usernameInput.value = mqtt.username; usernameInput.value = mqtt.username;
passwordInput.value = ''; passwordInput.value = '';
mqttForm.hidden = false; mqttForm.hidden = false;
scriptStatus.textContent = '';
await loadScriptIntoEditor(address);
scriptSection.hidden = false;
} catch (err) { } catch (err) {
statusLine.textContent = `Failed to reach device: ${err}`; statusLine.textContent = `Failed to reach device: ${err}`;
} }
@@ -88,6 +117,29 @@ mqttForm.addEventListener('submit', async (e) => {
} }
}); });
document.querySelector<HTMLButtonElement>('#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<HTMLButtonElement>('#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) { if (addressInput.value) {
connect(); connect();
} }

View File

@@ -1,10 +1,28 @@
body { body {
font-family: sans-serif; font-family: sans-serif;
max-width: 360px; max-width: 720px;
margin: 2em auto; margin: 2em auto;
padding: 0 1em; 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 { label {
display: block; display: block;
margin-top: 1em; margin-top: 1em;