Scaffold kvida-sdk: Vite + TypeScript web client for device settings

First real content in this repo. Runs as a standalone web app (not on
the ESP32 -- Blockly is far too large for the device's flash),
talking to a device already on the LAN via the JSON API kvida-os now
exposes (see kvida-os's transport README, "JSON API" section).

One page: enter a device address, fetch /api/status + /api/mqtt,
edit and save MQTT broker/credentials back via POST /api/mqtt. Proves
the API end-to-end from a real standalone app before any Blockly work
starts.

No framework yet -- kept minimal until the Blockly editor itself is
designed. Verified: npm install/build are clean (no TS errors), and
the dev server was confirmed reachable with the device's CORS headers
allowing cross-origin fetch() calls from it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Ronny Eia
2026-07-11 14:59:14 +02:00
parent 8017e0b834
commit 6468dddc05
9 changed files with 1341 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.DS_Store
*.log

BIN
README.md

Binary file not shown.

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kvida SDK</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1119
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "kvida-sdk",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "^5.6.0",
"vite": "^6.0.0"
}
}

57
src/device.ts Normal file
View File

@@ -0,0 +1,57 @@
// Thin client for the JSON API kvida-os exposes on the device's settings
// server (see kvida-os/components/transport/README.md, "JSON API"
// section). Talks to a device already on the LAN -- this has nothing to
// do with initial Wi-Fi setup, which stays on-device only (the captive
// portal has no route to this app).
export interface DeviceStatus {
device_id: string;
wifi_connected: boolean;
mqtt_connected: boolean;
}
export interface MqttConfig {
host: string;
port: number;
username: string;
}
export interface SaveMqttConfig {
host: string;
port: number;
username: string;
password: string; // blank = keep the current one
}
function baseUrl(address: string): string {
const trimmed = address.trim();
return trimmed.startsWith('http') ? trimmed : `http://${trimmed}`;
}
export async function getStatus(address: string): Promise<DeviceStatus> {
const res = await fetch(`${baseUrl(address)}/api/status`);
if (!res.ok) {
throw new Error(`GET /api/status failed: ${res.status}`);
}
return res.json();
}
export async function getMqttConfig(address: string): Promise<MqttConfig> {
const res = await fetch(`${baseUrl(address)}/api/mqtt`);
if (!res.ok) {
throw new Error(`GET /api/mqtt failed: ${res.status}`);
}
return res.json();
}
export async function saveMqttConfig(address: string, config: SaveMqttConfig): Promise<void> {
const res = await fetch(`${baseUrl(address)}/api/mqtt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? `POST /api/mqtt failed: ${res.status}`);
}
}

93
src/main.ts Normal file
View File

@@ -0,0 +1,93 @@
import { getMqttConfig, getStatus, saveMqttConfig } from './device';
import './style.css';
const STORAGE_KEY = 'kvida-sdk:device-address';
const app = document.querySelector<HTMLDivElement>('#app')!;
app.innerHTML = `
<main>
<h1>Kvida device settings</h1>
<label>Device address
<input id="address" type="text" placeholder="kvida-xxxxxx.local" />
</label>
<button id="connect">Connect</button>
<p id="status-line"></p>
<form id="mqtt-form" hidden>
<h2>MQTT</h2>
<label>Broker host or IP
<input id="host" type="text" required />
</label>
<label>Port
<input id="port" type="number" />
</label>
<label>Username
<input id="username" type="text" />
</label>
<label>Password (leave blank to keep the current one)
<input id="password" type="password" />
</label>
<button type="submit">Save</button>
<p id="mqtt-status"></p>
</form>
</main>
`;
const addressInput = document.querySelector<HTMLInputElement>('#address')!;
const statusLine = document.querySelector<HTMLParagraphElement>('#status-line')!;
const mqttForm = document.querySelector<HTMLFormElement>('#mqtt-form')!;
const mqttStatus = document.querySelector<HTMLParagraphElement>('#mqtt-status')!;
const hostInput = document.querySelector<HTMLInputElement>('#host')!;
const portInput = document.querySelector<HTMLInputElement>('#port')!;
const usernameInput = document.querySelector<HTMLInputElement>('#username')!;
const passwordInput = document.querySelector<HTMLInputElement>('#password')!;
addressInput.value = localStorage.getItem(STORAGE_KEY) ?? '';
async function connect() {
const address = addressInput.value.trim();
if (!address) {
return;
}
localStorage.setItem(STORAGE_KEY, address);
statusLine.textContent = 'Connecting...';
mqttForm.hidden = true;
try {
const status = await getStatus(address);
statusLine.textContent =
`${status.device_id} -- Wi-Fi: ${status.wifi_connected ? 'connected' : 'offline'}, ` +
`MQTT: ${status.mqtt_connected ? 'connected' : 'offline'}`;
const mqtt = await getMqttConfig(address);
hostInput.value = mqtt.host;
portInput.value = String(mqtt.port);
usernameInput.value = mqtt.username;
passwordInput.value = '';
mqttForm.hidden = false;
} catch (err) {
statusLine.textContent = `Failed to reach device: ${err}`;
}
}
document.querySelector<HTMLButtonElement>('#connect')!.addEventListener('click', connect);
mqttForm.addEventListener('submit', async (e) => {
e.preventDefault();
mqttStatus.textContent = 'Saving...';
try {
await saveMqttConfig(addressInput.value.trim(), {
host: hostInput.value,
port: parseInt(portInput.value, 10) || 1883,
username: usernameInput.value,
password: passwordInput.value,
});
mqttStatus.textContent = 'Saved. Device is reconnecting to MQTT.';
} catch (err) {
mqttStatus.textContent = `Failed to save: ${err}`;
}
});
if (addressInput.value) {
connect();
}

23
src/style.css Normal file
View File

@@ -0,0 +1,23 @@
body {
font-family: sans-serif;
max-width: 360px;
margin: 2em auto;
padding: 0 1em;
}
label {
display: block;
margin-top: 1em;
}
input {
width: 100%;
box-sizing: border-box;
padding: 0.5em;
}
button {
margin-top: 1.5em;
width: 100%;
padding: 0.7em;
}

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}