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:
158
package-lock.json
generated
158
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<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
28
src/editor.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
54
src/main.ts
54
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 = `
|
||||
<button type="submit">Save</button>
|
||||
<p id="mqtt-status"></p>
|
||||
</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>
|
||||
`;
|
||||
|
||||
@@ -41,9 +51,23 @@ const hostInput = document.querySelector<HTMLInputElement>('#host')!;
|
||||
const portInput = document.querySelector<HTMLInputElement>('#port')!;
|
||||
const usernameInput = document.querySelector<HTMLInputElement>('#username')!;
|
||||
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) ?? '';
|
||||
|
||||
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<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) {
|
||||
connect();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user