This commit is contained in:
proddy
2026-09-03 21:24:40 +02:00
parent 6a1f4c9941
commit cc2865ebdd
7 changed files with 287 additions and 237 deletions
+106
View File
@@ -0,0 +1,106 @@
// Simulate EventSource for log messages in EMS-ESP standalone mode
// Server-Sent Events (SSE)
const ONE_SECOND_MS = 1000;
// padding function
const pad = (number) => String(number).padStart(2, '0');
// Cached date formatter to avoid prototype pollution
const formatDate = (date) => {
const year = date.getUTCFullYear();
const month = pad(date.getUTCMonth() + 1);
const day = pad(date.getUTCDate());
const hours = pad(date.getUTCHours());
const minutes = pad(date.getUTCMinutes());
const seconds = pad(date.getUTCSeconds());
const milliseconds = String((date.getUTCMilliseconds() / 1000).toFixed(3)).slice(
2,
5
);
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`;
};
export default () => {
return {
name: 'vite:mockserver',
configureServer: async (server) => {
server.middlewares.use(async (req, res, next) => {
// Handle Server-Sent Events (SSE) for log streaming
if (req.url.startsWith('/es/log')) {
// Set SSE headers
res.writeHead(200, {
Connection: 'keep-alive',
'Cache-Control': 'no-cache, no-transform',
'Content-Type': 'text/event-stream',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control',
'X-Accel-Buffering': 'no' // disable proxy buffering (nginx, etc.)
});
// Flush headers early when supported
if (typeof res.flushHeaders === 'function') {
res.flushHeaders();
}
let messageCount = 0;
const logLevels = [3, 4, 5, 6, 7, 8]; // Different log levels
const logNames = ['system', 'ems', 'wifi', 'mqtt', 'ntp', 'api'];
const sendLogMessage = () => {
const level = logLevels[messageCount % logLevels.length];
const name = logNames[messageCount % logNames.length];
let message = `Log message #${messageCount}`;
// Add long message every 6th message
if (messageCount % 6 === 1) {
message +=
' - This is a longer message to test text wrapping and truncation behavior in the UI';
}
const logData = {
t: formatDate(new Date()),
l: level,
i: messageCount,
n: name,
m: message
};
res.write(`data: ${JSON.stringify(logData)}\n\n`);
messageCount++;
};
// Send initial message
res.write(`retry: 2000\n\n`); // client reconnection delay
sendLogMessage();
// Set up interval for periodic messages
const messageInterval = setInterval(sendLogMessage, 500);
if (typeof messageInterval.unref === 'function') messageInterval.unref();
// Heartbeat to keep connections alive through proxies
const heartbeat = setInterval(() => {
res.write(`:keep-alive ${Date.now()}\n\n`);
}, 15 * ONE_SECOND_MS);
if (typeof heartbeat.unref === 'function') heartbeat.unref();
// Clean up on connection close
const cleanup = () => {
console.log('SSE connection closed');
clearInterval(messageInterval);
clearInterval(heartbeat);
if (!res.destroyed) {
res.end();
}
};
res.on('close', cleanup);
res.on('error', cleanup);
res.on('finish', cleanup);
} else {
next(); // Continue to next middleware (Vite proxy → restServer)
}
});
}
};
};
-218
View File
@@ -1,218 +0,0 @@
// Mock server for development
// Simulates file uploads and EventSource (SSE) for log messages
import formidable from 'formidable';
// Constants reused across requests
const VALID_EXTENSIONS = new Set(['bin', 'json', 'md5']);
const ONE_SECOND_MS = 1000;
const TEN_PERCENT = 10;
// padding function
const pad = (number) => String(number).padStart(2, '0');
// Simple throttle helper (time-based)
const throttle = (fn, intervalMs) => {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last >= intervalMs) {
last = now;
fn(...args);
}
};
};
// Cached date formatter to avoid prototype pollution
const formatDate = (date) => {
const year = date.getUTCFullYear();
const month = pad(date.getUTCMonth() + 1);
const day = pad(date.getUTCDate());
const hours = pad(date.getUTCHours());
const minutes = pad(date.getUTCMinutes());
const seconds = pad(date.getUTCSeconds());
const milliseconds = String((date.getUTCMilliseconds() / 1000).toFixed(3)).slice(
2,
5
);
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`;
};
export default () => {
return {
name: 'vite:mockserver',
configureServer: async (server) => {
server.middlewares.use(async (req, res, next) => {
// Handle file uploads
if (req.url.startsWith('/rest/uploadFile')) {
// CORS preflight support
if (req.method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Cache-Control',
'Access-Control-Max-Age': '600'
});
res.end();
return;
}
if (req.method !== 'POST') {
res.statusCode = 405;
res.setHeader('Allow', 'POST, OPTIONS');
res.end('Method Not Allowed');
return;
}
const fileSize = parseInt(req.headers['content-length'] || '0', 10);
let progress = 0;
// Track upload progress
const logThrottled = throttle((percentage) => {
console.log(`Upload progress: ${percentage}%`);
}, ONE_SECOND_MS);
req.on('data', (chunk) => {
progress += chunk.length;
if (fileSize > 0) {
const percentage = Math.round((progress / fileSize) * 100);
// Only log every ~1s and for meaningful changes (>=10%)
if (percentage % TEN_PERCENT === 0) {
logThrottled(percentage);
}
}
});
try {
const form = formidable({
maxFileSize: 50 * 1024 * 1024, // 50MB limit
keepExtensions: true,
multiples: false,
allowEmptyFiles: false
});
const [fields, files] = await form.parse(req);
if (Object.keys(files).length === 0) {
res.statusCode = 400;
res.end('No file uploaded');
return;
}
const uploadedFile = Array.isArray(files.file)
? files.file[0]
: files.file;
const fileName = uploadedFile.originalFilename;
const fileExtension = fileName
.substring(fileName.lastIndexOf('.') + 1)
.toLowerCase();
console.log(
`File uploaded: ${fileName} (${fileExtension}, ${fileSize} bytes)`
);
// Validate file extension
if (!VALID_EXTENSIONS.has(fileExtension)) {
res.statusCode = 406;
res.end('Invalid file extension');
return;
}
// Handle different file types
if (fileExtension === 'md5') {
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({
md5: 'ef4304fc4d9025a58dcf25d71c882d2c'
})
);
} else {
console.log('File uploaded successfully!');
res.end();
}
} catch (err) {
console.error('Upload error:', err && err.message ? err.message : err);
res.statusCode = err.httpCode || 400;
res.setHeader('Content-Type', 'text/plain');
res.end(err && err.message ? err.message : 'Upload error');
}
}
// Handle Server-Sent Events (SSE) for log streaming
else if (req.url.startsWith('/es/log')) {
// Set SSE headers
res.writeHead(200, {
Connection: 'keep-alive',
'Cache-Control': 'no-cache, no-transform',
'Content-Type': 'text/event-stream',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control',
'X-Accel-Buffering': 'no' // disable proxy buffering (nginx, etc.)
});
// Flush headers early when supported
if (typeof res.flushHeaders === 'function') {
res.flushHeaders();
}
let messageCount = 0;
const logLevels = [3, 4, 5, 6, 7, 8]; // Different log levels
const logNames = ['system', 'ems', 'wifi', 'mqtt', 'ntp', 'api'];
const sendLogMessage = () => {
const level = logLevels[messageCount % logLevels.length];
const name = logNames[messageCount % logNames.length];
let message = `Log message #${messageCount}`;
// Add long message every 6th message
if (messageCount % 6 === 1) {
message +=
' - This is a longer message to test text wrapping and truncation behavior in the UI';
}
const logData = {
t: formatDate(new Date()),
l: level,
i: messageCount,
n: name,
m: message
};
res.write(`data: ${JSON.stringify(logData)}\n\n`);
messageCount++;
};
// Send initial message
res.write(`retry: 2000\n\n`); // client reconnection delay
sendLogMessage();
// Set up interval for periodic messages
const messageInterval = setInterval(sendLogMessage, 500);
if (typeof messageInterval.unref === 'function') messageInterval.unref();
// Heartbeat to keep connections alive through proxies
const heartbeat = setInterval(() => {
res.write(`:keep-alive ${Date.now()}\n\n`);
}, 15 * ONE_SECOND_MS);
if (typeof heartbeat.unref === 'function') heartbeat.unref();
// Clean up on connection close
const cleanup = () => {
console.log('SSE connection closed');
clearInterval(messageInterval);
clearInterval(heartbeat);
if (!res.destroyed) {
res.end();
}
};
res.on('close', cleanup);
res.on('error', cleanup);
res.on('finish', cleanup);
} else {
next(); // Continue to next middleware
}
});
}
};
};
+111 -6
View File
@@ -239,6 +239,65 @@ switch (emulate_esp) {
// GLOBAL VARIABLES
let countWifiScanPoll = 0; // wifi network scan
let countHardwarePoll = 0; // for during an upload
let pendingFirmwareMd5 = false; // set by a .md5 upload, cleared by the next .bin
const VALID_UPLOAD_EXTENSIONS = new Set(['bin', 'json', 'md5']);
const UPLOAD_PROGRESS_LOG_STEP = 10; // log every 10%
// A real device takes a while to accept a firmware image, so uploads are read at
// this rate instead of completing instantly. That keeps the WebUI progress bar on
// screen long enough to see. Lower it to watch the progress bar for longer.
const UPLOAD_SIMULATED_KB_PER_SEC = 2048;
const UPLOAD_SLICE_BYTES = 64 * 1024; // granularity of the simulated transfer
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function parseMd5Digest(text: string): string | null {
const token = text.trim().split(/\s+/)[0];
if (/^[0-9a-fA-F]{32}$/.test(token)) {
return token.toLowerCase();
}
return null;
}
// Read the multipart body in slices at UPLOAD_SIMULATED_KB_PER_SEC, logging progress
// as it goes, then hand the buffered bytes back as FormData so they parse normally.
async function readUploadSlowly(request: Request): Promise<FormData> {
const contentType = request.headers.get('content-type') || '';
const expected = Number(request.headers.get('content-length') || '0');
const reader = request.body?.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
let loggedPercentage = 0;
while (reader) {
const { done, value } = await reader.read();
if (done) {
break;
}
chunks.push(value);
// A fast local client arrives in very few chunks, so pace each one slice by
// slice to keep the reported progress gradual instead of jumping to 100%
for (let offset = 0; offset < value.length; offset += UPLOAD_SLICE_BYTES) {
const sliceLength = Math.min(UPLOAD_SLICE_BYTES, value.length - offset);
await sleep((sliceLength / (UPLOAD_SIMULATED_KB_PER_SEC * 1024)) * 1000);
received += sliceLength;
if (expected > 0) {
const percentage = Math.round((received / expected) * 100);
if (percentage >= loggedPercentage + UPLOAD_PROGRESS_LOG_STEP) {
loggedPercentage = percentage;
console.log(`Upload progress: ${percentage}%`);
}
}
}
}
return new Response(new Blob(chunks as BlobPart[]), {
headers: { 'content-type': contentType }
}).formData();
}
// DeviceTypes
const enum ScheduleFlag {
@@ -434,16 +493,20 @@ function upgradeImportantMessages(version: string) {
// 0 is do nothing
// 1 means 3.9 and factory reset required
// 2 means a major version upgrade
let upgradeImportantMessageType_n = 0;
// see if its a filename with a .bin extension
if (version.endsWith('.bin')) {
upgradeImportantMessageType_n = 1; // make it 1, for testing, meaning factory reset required
} else if (version.endsWith('.md')) {
upgradeImportantMessageType_n = 0; // use default 0, no message
// check file extensions
if (version.endsWith('.md5') || version.endsWith('.json')) {
upgradeImportantMessageType_n = 0; // digest / backup restore: no upgrade warning
} else if (version.endsWith('.bin')) {
// extract the version number from the filename and if its going from 3.8.x to 3.9.x, then set upgradeImportantMessageType_n to 1
const versionNumber = version.split('.');
if (versionNumber[0] === '3' && versionNumber[1] === '8') {
upgradeImportantMessageType_n = 1; // make it 1, factory reset required
}
} else {
// this is a version string like "3.9.0"
// upgradeImportantMessageType_n = 2; // make it 2, for testing, meaning a major version upgrade
upgradeImportantMessageType_n = 1; // make it 1, for testing, meaning a factory reset is required
}
@@ -774,6 +837,7 @@ const EMSESP_COMMANDS_ENDPOINT = REST_ENDPOINT_ROOT + 'commands';
const EMSESP_CUSTOMENTITIES_ENDPOINT = REST_ENDPOINT_ROOT + 'customEntities';
const EMSESP_MODULES_ENDPOINT = REST_ENDPOINT_ROOT + 'modules';
const EMSESP_ACTION_ENDPOINT = REST_ENDPOINT_ROOT + 'action';
const EMSESP_UPLOAD_FILE_ENDPOINT = REST_ENDPOINT_ROOT + 'uploadFile';
// these are used in the API calls only
const EMSESP_SYSTEM_INFO_ENDPOINT = API_ENDPOINT_ROOT + 'system/info';
@@ -4637,6 +4701,47 @@ router
// SYSTEM and SETTINGS
router
.post(EMSESP_UPLOAD_FILE_ENDPOINT, async (request: Request) => {
const formData = await readUploadSlowly(request);
const uploaded = formData.get('file');
if (!uploaded || typeof uploaded === 'string') {
return status(400);
}
const fileName = uploaded.name || '';
const fileExtension = fileName.includes('.')
? fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase()
: '';
console.log(
`File uploaded: ${fileName} (${fileExtension}, ${uploaded.size} bytes)`
);
if (!VALID_UPLOAD_EXTENSIONS.has(fileExtension)) {
console.log('Invalid file extension');
return status(406);
}
if (fileExtension === 'md5') {
const digest = parseMd5Digest(await uploaded.text());
if (!digest) {
console.log('Invalid MD5 digest file');
return status(406);
}
pendingFirmwareMd5 = true;
console.log('MD5 digest received', digest);
return { md5: digest };
}
if (fileExtension === 'bin' && pendingFirmwareMd5) {
pendingFirmwareMd5 = false;
console.log('Firmware MD5 matches');
return { md5_ok: true };
}
pendingFirmwareMd5 = false;
console.log('File uploaded successfully!');
return status(200);
})
.get(ACTIVITY_ENDPOINT, () => activity)
.get(SYSTEM_STATUS_ENDPOINT, async () => {
if (countHardwarePoll >= 2) {