diff --git a/interface/src/components/upload/SingleUpload.tsx b/interface/src/components/upload/SingleUpload.tsx
index 3f698b29e..134cb9918 100644
--- a/interface/src/components/upload/SingleUpload.tsx
+++ b/interface/src/components/upload/SingleUpload.tsx
@@ -29,11 +29,22 @@ const SingleUpload = ({ doRestart }: SingleUploadProps) => {
} = useRequest(SystemApi.uploadFile, {
immediate: false
}).onSuccess(({ data }) => {
- if (data && typeof data === 'object' && 'md5' in data) {
- setMd5((data as { md5: string }).md5);
- toast.success(LL.UPLOAD() + ' MD5 ' + LL.SUCCESSFUL());
+ let payload = data;
+ if (typeof payload === 'string' && payload.length > 0) {
+ 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);
} else {
+ if (payload && typeof payload === 'object' && 'md5_ok' in payload) {
+ toast.success(LL.UPLOAD_MD5_MATCHED());
+ }
doRestart();
}
});
@@ -80,12 +91,17 @@ const SingleUpload = ({ doRestart }: SingleUploadProps) => {
>
) : (
-
+
)}
{md5 && (
- {'MD5: ' + md5}
+
+ {'MD5: ' + md5}
+
)}
>
diff --git a/mock-api/mockSSEServer.js b/mock-api/mockSSEServer.js
new file mode 100644
index 000000000..914164d8d
--- /dev/null
+++ b/mock-api/mockSSEServer.js
@@ -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)
+ }
+ });
+ }
+ };
+};
diff --git a/mock-api/mockServer.js b/mock-api/mockServer.js
deleted file mode 100644
index 2987ac012..000000000
--- a/mock-api/mockServer.js
+++ /dev/null
@@ -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
- }
- });
- }
- };
-};
diff --git a/mock-api/restServer.ts b/mock-api/restServer.ts
index de1fce771..a2ba1e60f 100644
--- a/mock-api/restServer.ts
+++ b/mock-api/restServer.ts
@@ -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 {
+ 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) {
diff --git a/project-words.txt b/project-words.txt
index 2aa5d4e27..3204475a4 100644
--- a/project-words.txt
+++ b/project-words.txt
@@ -744,7 +744,7 @@ mlongcalls
mmio
mmode
mmplus
-mockserver
+mockSSEServer
modbus
modee
modetype
diff --git a/src/ESP32React/UploadFileService.cpp b/src/ESP32React/UploadFileService.cpp
index 240701ef2..a10266192 100644
--- a/src/ESP32React/UploadFileService.cpp
+++ b/src/ESP32React/UploadFileService.cpp
@@ -13,10 +13,32 @@ static String getFilenameExtension(const String & filename) {
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 & 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(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(c - 'A' + 'a') : c;
+ }
+ out[32] = '\0';
+ return true;
+}
+
UploadFileService::UploadFileService(AsyncWebServer * server, SecurityManager * securityManager)
: _securityManager(securityManager)
, _is_firmware(false)
, _is_filesystem(false)
+ , _md5_applied(false)
, _md5() {
server->on(
UPLOAD_FILE_PATH,
@@ -48,18 +70,23 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri
// LittleFS filesystem image
_is_filesystem = true;
_md5[0] = '\0'; // clear any stale md5 so Update.end() doesn't compare against it
+ _md5_applied = false;
} else if ((extension == "bin") && (filesize > 1000000)) {
_is_firmware = true;
} else if (extension == "json") {
- _md5[0] = '\0'; // clear md5
+ _md5[0] = '\0'; // clear md5
+ _md5_applied = false;
} else if (extension == "md5") {
- if (len == _md5.size() - 1) {
- std::memcpy(_md5.data(), data, _md5.size() - 1);
- _md5.back() = '\0';
+ if (!parseMd5Digest(data, len, _md5)) {
+ emsesp::EMSESP::logger().err("Invalid MD5 digest file");
+ handleError(request, 406); // Not Acceptable
+ } else {
+ emsesp::EMSESP::logger().info("MD5 digest received (%s). Now upload the firmware BIN", _md5.data());
}
return;
} else {
_md5.front() = '\0';
+ _md5_applied = false;
emsesp::EMSESP::logger().err("Unsupported file type: %s, size: %u", filename.c_str(), filesize);
handleError(request, 406); // Not Acceptable - unsupported file type
return;
@@ -103,9 +130,11 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri
emsesp::EMSESP::system_.systemStatus(emsesp::SYSTEM_STATUS::SYSTEM_STATUS_UPLOADING);
if (Update.begin(filesize - sizeof(esp_image_header_t))) {
+ _md5_applied = false;
if (strlen(_md5.data()) == _md5.size() - 1) {
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
} else {
@@ -183,8 +212,19 @@ void UploadFileService::uploadComplete(AsyncWebServerRequest * request) {
emsesp::EMSESP::nvs_.putBool(emsesp::EMSESP_NVS_BOOT_NEW_FIRMWARE, true);
}
- AsyncWebServerResponse * response = request->beginResponse(200);
- request->send(response);
+ 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);
+ request->send(response);
+ }
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
return;
diff --git a/src/ESP32React/UploadFileService.h b/src/ESP32React/UploadFileService.h
index 4c8a20dbb..9da611c7a 100644
--- a/src/ESP32React/UploadFileService.h
+++ b/src/ESP32React/UploadFileService.h
@@ -23,6 +23,7 @@ class UploadFileService {
SecurityManager * _securityManager;
bool _is_firmware;
bool _is_filesystem;
+ bool _md5_applied; // true if an MD5 digest was applied to the current firmware upload
std::array _md5;
void handleUpload(AsyncWebServerRequest * request, const String & filename, size_t index, uint8_t * data, size_t len, bool final);