mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2026-09-13 05:34:34 +00:00
This commit is contained in:
@@ -29,11 +29,22 @@ const SingleUpload = ({ doRestart }: SingleUploadProps) => {
|
|||||||
} = useRequest(SystemApi.uploadFile, {
|
} = useRequest(SystemApi.uploadFile, {
|
||||||
immediate: false
|
immediate: false
|
||||||
}).onSuccess(({ data }) => {
|
}).onSuccess(({ data }) => {
|
||||||
if (data && typeof data === 'object' && 'md5' in data) {
|
let payload = data;
|
||||||
setMd5((data as { md5: string }).md5);
|
if (typeof payload === 'string' && payload.length > 0) {
|
||||||
toast.success(LL.UPLOAD() + ' MD5 ' + LL.SUCCESSFUL());
|
try {
|
||||||
|
payload = JSON.parse(payload);
|
||||||
|
} catch {
|
||||||
|
payload = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (payload && typeof payload === 'object' && 'md5' in payload) {
|
||||||
|
setMd5((payload as { md5: string }).md5);
|
||||||
|
toast.success(LL.UPLOAD_MD5_RECEIVED());
|
||||||
setFile(undefined);
|
setFile(undefined);
|
||||||
} else {
|
} else {
|
||||||
|
if (payload && typeof payload === 'object' && 'md5_ok' in payload) {
|
||||||
|
toast.success(LL.UPLOAD_MD5_MATCHED());
|
||||||
|
}
|
||||||
doRestart();
|
doRestart();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -80,12 +91,17 @@ const SingleUpload = ({ doRestart }: SingleUploadProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<DragNdrop text={LL.UPLOAD_DROP_TEXT()} onFileSelected={setFile} />
|
<DragNdrop
|
||||||
|
text={(md5 ? LL.UPLOAD_MD5_RECEIVED() : LL.UPLOAD_DROP_TEXT()) + '...'}
|
||||||
|
onFileSelected={setFile}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{md5 && (
|
{md5 && (
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<Typography variant="body2">{'MD5: ' + md5}</Typography>
|
<Typography variant="body2" color="success">
|
||||||
|
{'MD5: ' + md5}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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
@@ -239,6 +239,65 @@ switch (emulate_esp) {
|
|||||||
// GLOBAL VARIABLES
|
// GLOBAL VARIABLES
|
||||||
let countWifiScanPoll = 0; // wifi network scan
|
let countWifiScanPoll = 0; // wifi network scan
|
||||||
let countHardwarePoll = 0; // for during an upload
|
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
|
// DeviceTypes
|
||||||
const enum ScheduleFlag {
|
const enum ScheduleFlag {
|
||||||
@@ -434,16 +493,20 @@ function upgradeImportantMessages(version: string) {
|
|||||||
// 0 is do nothing
|
// 0 is do nothing
|
||||||
// 1 means 3.9 and factory reset required
|
// 1 means 3.9 and factory reset required
|
||||||
// 2 means a major version upgrade
|
// 2 means a major version upgrade
|
||||||
|
|
||||||
let upgradeImportantMessageType_n = 0;
|
let upgradeImportantMessageType_n = 0;
|
||||||
|
|
||||||
// see if its a filename with a .bin extension
|
// check file extensions
|
||||||
if (version.endsWith('.bin')) {
|
if (version.endsWith('.md5') || version.endsWith('.json')) {
|
||||||
upgradeImportantMessageType_n = 1; // make it 1, for testing, meaning factory reset required
|
upgradeImportantMessageType_n = 0; // digest / backup restore: no upgrade warning
|
||||||
} else if (version.endsWith('.md')) {
|
} else if (version.endsWith('.bin')) {
|
||||||
upgradeImportantMessageType_n = 0; // use default 0, no message
|
// 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 {
|
} else {
|
||||||
// this is a version string like "3.9.0"
|
// 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
|
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_CUSTOMENTITIES_ENDPOINT = REST_ENDPOINT_ROOT + 'customEntities';
|
||||||
const EMSESP_MODULES_ENDPOINT = REST_ENDPOINT_ROOT + 'modules';
|
const EMSESP_MODULES_ENDPOINT = REST_ENDPOINT_ROOT + 'modules';
|
||||||
const EMSESP_ACTION_ENDPOINT = REST_ENDPOINT_ROOT + 'action';
|
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
|
// these are used in the API calls only
|
||||||
const EMSESP_SYSTEM_INFO_ENDPOINT = API_ENDPOINT_ROOT + 'system/info';
|
const EMSESP_SYSTEM_INFO_ENDPOINT = API_ENDPOINT_ROOT + 'system/info';
|
||||||
@@ -4637,6 +4701,47 @@ router
|
|||||||
|
|
||||||
// SYSTEM and SETTINGS
|
// SYSTEM and SETTINGS
|
||||||
router
|
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(ACTIVITY_ENDPOINT, () => activity)
|
||||||
.get(SYSTEM_STATUS_ENDPOINT, async () => {
|
.get(SYSTEM_STATUS_ENDPOINT, async () => {
|
||||||
if (countHardwarePoll >= 2) {
|
if (countHardwarePoll >= 2) {
|
||||||
|
|||||||
+1
-1
@@ -744,7 +744,7 @@ mlongcalls
|
|||||||
mmio
|
mmio
|
||||||
mmode
|
mmode
|
||||||
mmplus
|
mmplus
|
||||||
mockserver
|
mockSSEServer
|
||||||
modbus
|
modbus
|
||||||
modee
|
modee
|
||||||
modetype
|
modetype
|
||||||
|
|||||||
@@ -13,10 +13,32 @@ static String getFilenameExtension(const String & filename) {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accepts a raw 32-char hex digest, optionally with trailing newline or a GNU "hash filename" suffix.
|
||||||
|
static bool parseMd5Digest(const uint8_t * data, size_t len, std::array<char, 33> & out) {
|
||||||
|
size_t i = 0;
|
||||||
|
while (i < len && (data[i] == ' ' || data[i] == '\t' || data[i] == '\r' || data[i] == '\n')) {
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
if (len - i < 32) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (size_t n = 0; n < 32; n++) {
|
||||||
|
const char c = static_cast<char>(data[i + n]);
|
||||||
|
const bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
|
||||||
|
if (!hex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
out[n] = (c >= 'A' && c <= 'F') ? static_cast<char>(c - 'A' + 'a') : c;
|
||||||
|
}
|
||||||
|
out[32] = '\0';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
UploadFileService::UploadFileService(AsyncWebServer * server, SecurityManager * securityManager)
|
UploadFileService::UploadFileService(AsyncWebServer * server, SecurityManager * securityManager)
|
||||||
: _securityManager(securityManager)
|
: _securityManager(securityManager)
|
||||||
, _is_firmware(false)
|
, _is_firmware(false)
|
||||||
, _is_filesystem(false)
|
, _is_filesystem(false)
|
||||||
|
, _md5_applied(false)
|
||||||
, _md5() {
|
, _md5() {
|
||||||
server->on(
|
server->on(
|
||||||
UPLOAD_FILE_PATH,
|
UPLOAD_FILE_PATH,
|
||||||
@@ -48,18 +70,23 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri
|
|||||||
// LittleFS filesystem image
|
// LittleFS filesystem image
|
||||||
_is_filesystem = true;
|
_is_filesystem = true;
|
||||||
_md5[0] = '\0'; // clear any stale md5 so Update.end() doesn't compare against it
|
_md5[0] = '\0'; // clear any stale md5 so Update.end() doesn't compare against it
|
||||||
|
_md5_applied = false;
|
||||||
} else if ((extension == "bin") && (filesize > 1000000)) {
|
} else if ((extension == "bin") && (filesize > 1000000)) {
|
||||||
_is_firmware = true;
|
_is_firmware = true;
|
||||||
} else if (extension == "json") {
|
} else if (extension == "json") {
|
||||||
_md5[0] = '\0'; // clear md5
|
_md5[0] = '\0'; // clear md5
|
||||||
|
_md5_applied = false;
|
||||||
} else if (extension == "md5") {
|
} else if (extension == "md5") {
|
||||||
if (len == _md5.size() - 1) {
|
if (!parseMd5Digest(data, len, _md5)) {
|
||||||
std::memcpy(_md5.data(), data, _md5.size() - 1);
|
emsesp::EMSESP::logger().err("Invalid MD5 digest file");
|
||||||
_md5.back() = '\0';
|
handleError(request, 406); // Not Acceptable
|
||||||
|
} else {
|
||||||
|
emsesp::EMSESP::logger().info("MD5 digest received (%s). Now upload the firmware BIN", _md5.data());
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
_md5.front() = '\0';
|
_md5.front() = '\0';
|
||||||
|
_md5_applied = false;
|
||||||
emsesp::EMSESP::logger().err("Unsupported file type: %s, size: %u", filename.c_str(), filesize);
|
emsesp::EMSESP::logger().err("Unsupported file type: %s, size: %u", filename.c_str(), filesize);
|
||||||
handleError(request, 406); // Not Acceptable - unsupported file type
|
handleError(request, 406); // Not Acceptable - unsupported file type
|
||||||
return;
|
return;
|
||||||
@@ -103,9 +130,11 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri
|
|||||||
emsesp::EMSESP::system_.systemStatus(emsesp::SYSTEM_STATUS::SYSTEM_STATUS_UPLOADING);
|
emsesp::EMSESP::system_.systemStatus(emsesp::SYSTEM_STATUS::SYSTEM_STATUS_UPLOADING);
|
||||||
|
|
||||||
if (Update.begin(filesize - sizeof(esp_image_header_t))) {
|
if (Update.begin(filesize - sizeof(esp_image_header_t))) {
|
||||||
|
_md5_applied = false;
|
||||||
if (strlen(_md5.data()) == _md5.size() - 1) {
|
if (strlen(_md5.data()) == _md5.size() - 1) {
|
||||||
Update.setMD5(_md5.data());
|
Update.setMD5(_md5.data());
|
||||||
_md5.front() = '\0';
|
_md5_applied = true;
|
||||||
|
emsesp::EMSESP::logger().info("Firmware MD5 check enabled (%s)", _md5.data());
|
||||||
}
|
}
|
||||||
request->onDisconnect([this] { handleDisconnect(); }); // success, let's make sure we end the update if the client hangs up
|
request->onDisconnect([this] { handleDisconnect(); }); // success, let's make sure we end the update if the client hangs up
|
||||||
} else {
|
} else {
|
||||||
@@ -183,8 +212,19 @@ void UploadFileService::uploadComplete(AsyncWebServerRequest * request) {
|
|||||||
emsesp::EMSESP::nvs_.putBool(emsesp::EMSESP_NVS_BOOT_NEW_FIRMWARE, true);
|
emsesp::EMSESP::nvs_.putBool(emsesp::EMSESP_NVS_BOOT_NEW_FIRMWARE, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_is_firmware && _md5_applied) {
|
||||||
|
emsesp::EMSESP::logger().info("Firmware MD5 matches");
|
||||||
|
auto * response = new emsesp::PsramAsyncJsonResponse(false);
|
||||||
|
JsonObject root = response->getRoot();
|
||||||
|
root["md5_ok"] = true;
|
||||||
|
response->setLength();
|
||||||
|
request->send(response);
|
||||||
|
_md5.front() = '\0';
|
||||||
|
_md5_applied = false;
|
||||||
|
} else {
|
||||||
AsyncWebServerResponse * response = request->beginResponse(200);
|
AsyncWebServerResponse * response = request->beginResponse(200);
|
||||||
request->send(response);
|
request->send(response);
|
||||||
|
}
|
||||||
emsesp::EMSESP::system_.systemStatus(
|
emsesp::EMSESP::system_.systemStatus(
|
||||||
emsesp::SYSTEM_STATUS::SYSTEM_STATUS_PENDING_RESTART); // will be handled by the main loop. We use pending for the Web's SystemMonitor
|
emsesp::SYSTEM_STATUS::SYSTEM_STATUS_PENDING_RESTART); // will be handled by the main loop. We use pending for the Web's SystemMonitor
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class UploadFileService {
|
|||||||
SecurityManager * _securityManager;
|
SecurityManager * _securityManager;
|
||||||
bool _is_firmware;
|
bool _is_firmware;
|
||||||
bool _is_filesystem;
|
bool _is_filesystem;
|
||||||
|
bool _md5_applied; // true if an MD5 digest was applied to the current firmware upload
|
||||||
std::array<char, 33> _md5;
|
std::array<char, 33> _md5;
|
||||||
|
|
||||||
void handleUpload(AsyncWebServerRequest * request, const String & filename, size_t index, uint8_t * data, size_t len, bool final);
|
void handleUpload(AsyncWebServerRequest * request, const String & filename, size_t index, uint8_t * data, size_t len, bool final);
|
||||||
|
|||||||
Reference in New Issue
Block a user