Initial sanitized SmartSpeaker snapshot

This commit is contained in:
ben
2026-07-13 21:13:19 -07:00
commit ac26dcc238
66 changed files with 11438 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"watch": ["src"],
"ext": "ts",
"exec": "ts-node src/server.ts"
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "dev-server",
"version": "1.0.0",
"description": "TypeScript Socket Server for ESP32 Audio Board",
"main": "dist/server.js",
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "nodemon"
},
"dependencies": {
"@types/multer": "^2.1.0",
"express": "^4.19.2",
"multer": "^2.2.0",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.12.7",
"@types/ws": "^8.5.10",
"nodemon": "^3.1.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
}
}
+7
View File
@@ -0,0 +1,7 @@
@echo off
set /p tunnel_name="Enter Cloudflare tunnel name [default: home dev]: "
if "%tunnel_name%"=="" set tunnel_name=home dev
echo Running Cloudflare tunnel "%tunnel_name%" -> http://localhost:8901...
"C:\Program Files (x86)\cloudflared\cloudflared.exe" tunnel run "%tunnel_name%"
pause
+5
View File
@@ -0,0 +1,5 @@
param (
[string]$Tunnel = "home dev"
)
Write-Host "Running Cloudflare tunnel: $Tunnel -> http://localhost:8901..." -ForegroundColor Cyan
& "C:\Program Files (x86)\cloudflared\cloudflared.exe" tunnel run $Tunnel
+462
View File
@@ -0,0 +1,462 @@
import express from 'express';
import http from 'http';
import { WebSocketServer, WebSocket } from 'ws';
import url from 'url';
import path from 'path';
import fs from 'fs';
import multer from 'multer';
import os from 'os';
function getLocalIp(): string {
const interfaces = os.networkInterfaces();
for (const name of Object.keys(interfaces)) {
for (const iface of interfaces[name] || []) {
if (iface.family === 'IPv4' && !iface.internal) {
return iface.address;
}
}
}
return 'localhost';
}
const app = express();
const port = process.env.PORT || 8901;
// CORS — allow the monitor-server dashboard (port 8902) to call OTA HTTP endpoints
app.use((_req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
next();
});
app.options('*', (_req, res) => res.sendStatus(200));
// OTA firmware storage
const OTA_DIR = path.join(__dirname, '..', 'ota_uploads');
if (!fs.existsSync(OTA_DIR)) fs.mkdirSync(OTA_DIR, { recursive: true });
const OTA_FILE = path.join(OTA_DIR, 'firmware.bin');
let otaFirmware: { filename: string; size: number; uploadedAt: string } | null = null;
const otaStorage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, OTA_DIR),
filename: (_req, _file, cb) => cb(null, 'firmware.bin'),
});
const upload = multer({
storage: otaStorage,
fileFilter: (_req, file, cb) => {
if (file.originalname.endsWith('.bin')) cb(null, true);
else cb(new Error('Only .bin firmware files are accepted'));
},
limits: { fileSize: 8 * 1024 * 1024 }, // 8 MB max
});
// Simple HTTP health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'OK', time: new Date() });
});
// Debug endpoint: dump connected clients as JSON
app.get('/clients', (_req, res) => {
res.json(Array.from(clients.values()));
});
// OTA: upload firmware binary
app.post('/ota/upload', upload.single('firmware'), (req, res) => {
if (!req.file) {
res.status(400).json({ error: 'No firmware file provided' });
return;
}
otaFirmware = {
filename: req.file.originalname,
size: req.file.size,
uploadedAt: new Date().toISOString(),
};
console.log(`[OTA] Firmware uploaded: ${req.file.originalname} (${req.file.size} bytes)`);
addLog('message', 'dashboard', `OTA firmware uploaded: ${req.file.originalname} (${(req.file.size / 1024).toFixed(1)} KB)`);
broadcastToMonitors();
res.json({ ok: true, ...otaFirmware });
});
// OTA: serve firmware binary
app.get('/ota/firmware.bin', (_req, res) => {
if (!otaFirmware || !fs.existsSync(OTA_FILE)) {
res.status(404).json({ error: 'No firmware uploaded yet' });
return;
}
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', 'attachment; filename="firmware.bin"');
res.sendFile(OTA_FILE);
});
// OTA: metadata status (manually uploaded binary)
app.get('/ota/status', (_req, res) => {
res.json(otaFirmware || { error: 'No firmware uploaded' });
});
// OTA: firmware release catalog (populated by scripts/release.ps1)
const RELEASES_DIR = path.join(__dirname, '..', '..', 'releases');
const CATALOG_FILE = path.join(RELEASES_DIR, 'firmware-catalog.json');
app.get('/ota/catalog', (_req, res) => {
if (!fs.existsSync(CATALOG_FILE)) {
res.json({ latest: '', releases: [] });
return;
}
try {
let content = fs.readFileSync(CATALOG_FILE, 'utf8');
// Strip UTF-8 BOM if present (\uFEFF)
if (content.charCodeAt(0) === 0xFEFF) {
content = content.slice(1);
}
const catalog = JSON.parse(content);
res.json(catalog);
} catch (e: any) {
console.error('[Server] Catalog parse error:', e.message);
res.status(500).json({ error: 'Failed to parse firmware-catalog.json', details: e.message });
}
});
// OTA: serve versioned release binaries from releases/ directory
app.use('/releases', express.static(RELEASES_DIR, { dotfiles: 'deny' }));
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
interface ClientMetadata {
id: string;
ip: string;
connectedAt: string;
messageCount: number;
lastActive: string;
userAgent?: string;
otaStatus?: 'idle' | 'pending' | 'flashing' | 'complete' | 'error';
otaProgress?: number;
otaError?: string;
state?: {
model?: string;
heap?: number;
uptime?: number;
rssi?: number;
volume?: number;
led_state?: number;
led_brightness?: number;
haptic_intensity?: number;
ambient_light?: number;
touch_active?: boolean;
};
}
interface ActivityLog {
timestamp: string;
type: 'connect' | 'disconnect' | 'message' | 'error';
ip: string;
details: string;
}
// Track clients and monitors
const clients = new Map<WebSocket, ClientMetadata>();
const monitors = new Set<WebSocket>();
const activityLogs: ActivityLog[] = [];
// Helper to push a log
function addLog(type: ActivityLog['type'], ip: string, details: string) {
activityLogs.unshift({
timestamp: new Date().toISOString(),
type,
ip,
details
});
if (activityLogs.length > 50) {
activityLogs.pop();
}
}
// Helper to cleanly remove a client and notify monitors
function removeClient(ws: WebSocket, clientId: string, ip: string, reason: string) {
if (clients.has(ws)) {
clients.delete(ws);
addLog('disconnect', ip, `Client [${clientId}] ${reason}`);
console.log(`[Server] Client removed [${clientId}] from ${ip}${reason}`);
broadcastToMonitors();
}
}
// Helper to serialize and broadcast state to all monitors
// Also prunes dead monitor sockets in-place
function broadcastToMonitors() {
// Prune stale clients (sockets not in OPEN state)
for (const [clientWs, meta] of clients.entries()) {
if (clientWs.readyState !== WebSocket.OPEN) {
console.log(`[Server] Pruning stale client [${meta.id}] (readyState=${clientWs.readyState})`);
clients.delete(clientWs);
addLog('disconnect', meta.ip, `Client [${meta.id}] pruned (stale socket)`);
}
}
const connectedClients = Array.from(clients.values());
const payload = JSON.stringify({
type: 'status_update',
clients: connectedClients,
logs: activityLogs
});
for (const monitor of monitors) {
if (monitor.readyState === WebSocket.OPEN) {
monitor.send(payload);
} else {
monitors.delete(monitor);
}
}
}
wss.on('connection', (ws: WebSocket, req) => {
const forwarded = req.headers['x-forwarded-for'];
const rawIp = (typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : null)
|| req.socket.remoteAddress
|| 'unknown';
// Normalize IPv4-mapped IPv6 (::ffff:1.2.3.4 → 1.2.3.4)
const ip = rawIp.startsWith('::ffff:') ? rawIp.slice(7) : rawIp;
const userAgent = req.headers['user-agent'] || 'Unknown Device';
// Parse query parameters
const parsedUrl = url.parse(req.url || '', true);
const role = parsedUrl.query.role;
if (role === 'monitor') {
console.log(`[Server] Monitor client connected from ${ip}`);
monitors.add(ws);
// Send initial state immediately
ws.send(JSON.stringify({
type: 'status_update',
clients: Array.from(clients.values()),
logs: activityLogs
}));
ws.on('message', (message: string) => {
try {
const commandData = JSON.parse(message);
if (commandData.type === 'control_device') {
const { targetId, payload } = commandData;
let found = false;
for (const [clientWs, meta] of clients.entries()) {
if (meta.id === targetId) {
if (clientWs.readyState === WebSocket.OPEN) {
clientWs.send(JSON.stringify(payload));
found = true;
break;
}
}
}
if (found) {
console.log(`[Server] Routed control command from monitor [${ip}] to client [${targetId}]:`, payload);
addLog('message', ip, `Control: Sent [${payload.command}] to [${targetId}]`);
} else {
console.log(`[Server] Control command target client [${targetId}] not found or offline.`);
ws.send(JSON.stringify({ type: 'error', message: `Device ${targetId} not found or offline.` }));
}
broadcastToMonitors();
} else if (commandData.type === 'boot_device') {
const { targetId } = commandData;
let found = false;
for (const [clientWs, meta] of clients.entries()) {
if (meta.id === targetId) {
found = true;
console.log(`[Server] Booting client [${targetId}] by request from monitor [${ip}]`);
addLog('disconnect', meta.ip || ip, `Device [${targetId}] forcibly booted by monitor`);
clientWs.terminate();
clients.delete(clientWs);
break;
}
}
if (!found) {
ws.send(JSON.stringify({ type: 'error', message: `Device ${targetId} not found or already offline.` }));
}
broadcastToMonitors();
} else if (commandData.type === 'trigger_ota') {
const { targetId, version } = commandData;
let serverHost = req.headers.host || `localhost:${port}`;
const protocol = (serverHost.includes('localhost') || serverHost.includes('127.0.0.1') || serverHost.includes('[::1]')) ? 'http' : 'https';
if (serverHost.includes('localhost') || serverHost.includes('127.0.0.1') || serverHost.includes('[::1]')) {
const localIp = getLocalIp();
serverHost = serverHost.replace('localhost', localIp).replace('127.0.0.1', localIp).replace('[::1]', localIp);
}
let firmwareUrl: string | null = null;
let firmwareLabel: string = '';
if (version && version !== 'custom') {
// Load catalog to find the versioned binary
try {
if (fs.existsSync(CATALOG_FILE)) {
let content = fs.readFileSync(CATALOG_FILE, 'utf8');
if (content.charCodeAt(0) === 0xFEFF) {
content = content.slice(1);
}
const catalog = JSON.parse(content);
// Lookup target device model
let targetModel = 'DEV-1';
for (const [_, clientMeta] of clients.entries()) {
if (clientMeta.id === targetId && clientMeta.state) {
targetModel = clientMeta.state.model || 'DEV-1';
break;
}
}
const resolvedVersion = (version === 'latest') ? catalog.latest : version;
const entry = (catalog.releases || []).find(
(r: { version: string, model: string }) => r.version === resolvedVersion && r.model === targetModel
);
if (entry) {
firmwareUrl = `${protocol}://${serverHost}/releases/${entry.filename}`;
firmwareLabel = `${entry.filename} (${entry.version} - ${entry.model})`;
} else {
ws.send(JSON.stringify({ type: 'error', message: `Firmware version '${resolvedVersion}' for model '${targetModel}' not found in catalog.` }));
}
} else {
ws.send(JSON.stringify({ type: 'error', message: 'No firmware catalog available. Run scripts/release.ps1 first.' }));
}
} catch (e) {
ws.send(JSON.stringify({ type: 'error', message: 'Failed to read firmware catalog.' }));
}
} else {
// Manual upload path
if (!otaFirmware || !fs.existsSync(OTA_FILE)) {
ws.send(JSON.stringify({ type: 'error', message: 'No firmware uploaded. Use POST /ota/upload first.' }));
} else {
firmwareUrl = `${protocol}://${serverHost}/ota/firmware.bin`;
firmwareLabel = otaFirmware.filename;
}
}
if (firmwareUrl) {
let found = false;
for (const [clientWs, meta] of clients.entries()) {
if (meta.id === targetId && clientWs.readyState === WebSocket.OPEN) {
found = true;
meta.otaStatus = 'pending';
meta.otaProgress = 0;
meta.otaError = undefined;
const otaPayload: any = { command: 'start_ota', url: firmwareUrl };
clientWs.send(JSON.stringify(otaPayload));
console.log(`[OTA] Triggered OTA on [${targetId}] → ${firmwareUrl}`);
addLog('message', meta.ip, `OTA triggered on [${targetId}]: ${firmwareLabel}${firmwareUrl}`);
break;
}
}
if (!found) {
ws.send(JSON.stringify({ type: 'error', message: `Device ${targetId} not found or offline.` }));
}
}
broadcastToMonitors();
}
} catch (e) {
console.error('[Server] Failed to process monitor message:', e);
}
});
ws.on('close', () => {
console.log(`[Server] Monitor client disconnected from ${ip}`);
monitors.delete(ws);
});
return;
}
// Handle standard device connection
const clientId = Math.random().toString(36).substring(2, 9).toUpperCase();
const metadata: ClientMetadata = {
id: clientId,
ip,
connectedAt: new Date().toISOString(),
messageCount: 0,
lastActive: new Date().toISOString(),
userAgent
};
clients.set(ws, metadata);
console.log(`[Server] New client connected [${clientId}] from ${ip} (${userAgent})`);
addLog('connect', ip, `Client [${clientId}] connected`);
broadcastToMonitors();
// Send welcome package and request state immediately
ws.send(JSON.stringify({ type: 'welcome', message: 'Connected to dev-server!' }));
ws.send(JSON.stringify({ command: 'query_state' }));
ws.on('message', (message: string) => {
console.log(`[Server] Received message from [${clientId}]: ${message}`);
metadata.messageCount++;
metadata.lastActive = new Date().toISOString();
try {
const data = JSON.parse(message);
if (data.type === 'state_report') {
metadata.state = {
model: data.system?.model,
heap: data.health?.free_heap_bytes,
uptime: data.health?.uptime_seconds,
rssi: data.network?.wifi_rssi,
volume: data.peripherals?.audio?.volume,
led_state: data.peripherals?.led_ring?.state_id,
led_brightness: data.peripherals?.led_ring?.brightness_coef,
haptic_intensity: data.peripherals?.haptic_intensity,
ambient_light: data.peripherals?.ambient_light,
touch_active: data.peripherals?.touch_active
};
addLog('message', ip, `Telemetry from [${clientId}]: Model=${metadata.state.model}, Vol=${metadata.state.volume}%, Brightness=${metadata.state.led_brightness}, Haptics=${metadata.state.haptic_intensity}%, Light=${metadata.state.ambient_light}, Touch=${metadata.state.touch_active}`);
} else if (data.type === 'ota_progress') {
metadata.otaStatus = 'flashing';
metadata.otaProgress = data.percent ?? 0;
console.log(`[OTA] [${clientId}] progress: ${data.percent}% (${data.bytes_written}/${data.bytes_total} bytes)`);
} else if (data.type === 'ota_complete') {
metadata.otaStatus = 'complete';
metadata.otaProgress = 100;
addLog('message', ip, `OTA complete on [${clientId}] — device rebooting`);
console.log(`[OTA] [${clientId}] OTA complete, rebooting`);
} else if (data.type === 'ota_error') {
metadata.otaStatus = 'error';
metadata.otaError = data.reason ?? 'unknown error';
addLog('error', ip, `OTA error on [${clientId}]: ${data.reason}`);
console.error(`[OTA] [${clientId}] error: ${data.reason}`);
} else {
addLog('message', ip, `[${clientId}] sent message`);
}
ws.send(JSON.stringify({ type: 'echo', data }));
} catch (e) {
addLog('message', ip, `[${clientId}] sent raw message`);
ws.send(JSON.stringify({ type: 'raw', data: message.toString() }));
}
broadcastToMonitors();
});
ws.on('close', () => {
removeClient(ws, clientId, ip, 'disconnected');
});
ws.on('error', (error) => {
console.error(`[Server] WebSocket error for [${clientId}]:`, error.message);
addLog('error', ip, `Client [${clientId}] error: ${error.message}`);
removeClient(ws, clientId, ip, `disconnected with error: ${error.message}`);
});
});
// Periodic stale-client sweep every 30 seconds
// Catches connections that drop without sending a close frame (e.g. network cuts through Cloudflare tunnel)
setInterval(() => {
let pruned = 0;
for (const [clientWs, meta] of clients.entries()) {
if (clientWs.readyState !== WebSocket.OPEN) {
clients.delete(clientWs);
addLog('disconnect', meta.ip, `Client [${meta.id}] pruned by sweep (readyState=${clientWs.readyState})`);
pruned++;
}
}
if (pruned > 0) {
console.log(`[Server] Stale sweep pruned ${pruned} client(s)`);
broadcastToMonitors();
}
}, 30_000);
server.listen(port, () => {
console.log(`[Server] Socket server running on port ${port}`);
});
+30
View File
@@ -0,0 +1,30 @@
const WebSocket = require('ws');
const url = process.env.WS_URL || 'ws://localhost:8901';
console.log(`[Test Client] Attempting connection to ${url}...`);
const ws = new WebSocket(url);
ws.on('open', () => {
console.log('[Test Client] Connected successfully!');
// Send test packet
const payload = JSON.stringify({
type: 'test_connection',
sender: 'Node.js Test Client',
timestamp: new Date()
});
ws.send(payload);
console.log(`[Test Client] Sent payload: ${payload}`);
});
ws.on('message', (data) => {
console.log(`[Test Client] Received from server: ${data}`);
});
ws.on('close', (code, reason) => {
console.log(`[Test Client] Connection closed. Code: ${code}, Reason: ${reason || 'None'}`);
});
ws.on('error', (err) => {
console.error('[Test Client] WebSocket error:', err.message || err);
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}