|
|
|
@@ -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}`);
|
|
|
|
|
});
|