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:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
12
index.html
Normal file
12
index.html
Normal 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
1119
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
package.json
Normal file
15
package.json
Normal 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
57
src/device.ts
Normal 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
93
src/main.ts
Normal 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
23
src/style.css
Normal 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
18
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user