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