esp32tool 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +31 -0
  2. package/css/dark.css +156 -0
  3. package/css/light.css +156 -0
  4. package/css/style.css +870 -0
  5. package/dist/const.d.ts +277 -0
  6. package/dist/const.js +511 -0
  7. package/dist/esp_loader.d.ts +222 -0
  8. package/dist/esp_loader.js +1466 -0
  9. package/dist/index.d.ts +10 -0
  10. package/dist/index.js +15 -0
  11. package/dist/lib/spiffs/index.d.ts +15 -0
  12. package/dist/lib/spiffs/index.js +16 -0
  13. package/dist/lib/spiffs/spiffs.d.ts +26 -0
  14. package/dist/lib/spiffs/spiffs.js +132 -0
  15. package/dist/lib/spiffs/spiffsBlock.d.ts +36 -0
  16. package/dist/lib/spiffs/spiffsBlock.js +140 -0
  17. package/dist/lib/spiffs/spiffsConfig.d.ts +63 -0
  18. package/dist/lib/spiffs/spiffsConfig.js +79 -0
  19. package/dist/lib/spiffs/spiffsPage.d.ts +45 -0
  20. package/dist/lib/spiffs/spiffsPage.js +260 -0
  21. package/dist/lib/spiffs/spiffsReader.d.ts +19 -0
  22. package/dist/lib/spiffs/spiffsReader.js +192 -0
  23. package/dist/partition.d.ts +26 -0
  24. package/dist/partition.js +129 -0
  25. package/dist/struct.d.ts +2 -0
  26. package/dist/struct.js +91 -0
  27. package/dist/stubs/esp32.json +8 -0
  28. package/dist/stubs/esp32c2.json +8 -0
  29. package/dist/stubs/esp32c3.json +8 -0
  30. package/dist/stubs/esp32c5.json +8 -0
  31. package/dist/stubs/esp32c6.json +8 -0
  32. package/dist/stubs/esp32c61.json +8 -0
  33. package/dist/stubs/esp32h2.json +8 -0
  34. package/dist/stubs/esp32p4.json +8 -0
  35. package/dist/stubs/esp32p4r3.json +8 -0
  36. package/dist/stubs/esp32s2.json +8 -0
  37. package/dist/stubs/esp32s3.json +8 -0
  38. package/dist/stubs/esp8266.json +8 -0
  39. package/dist/stubs/index.d.ts +10 -0
  40. package/dist/stubs/index.js +56 -0
  41. package/dist/util.d.ts +14 -0
  42. package/dist/util.js +46 -0
  43. package/dist/wasm/filesystems.d.ts +33 -0
  44. package/dist/wasm/filesystems.js +114 -0
  45. package/dist/web/esp32-D955RjN9.js +16 -0
  46. package/dist/web/esp32c2-CJkxHDQi.js +16 -0
  47. package/dist/web/esp32c3-BhUHzH0o.js +16 -0
  48. package/dist/web/esp32c5-Chs0HtmA.js +16 -0
  49. package/dist/web/esp32c6-D6mPN6ut.js +16 -0
  50. package/dist/web/esp32c61-CQiYCWAs.js +16 -0
  51. package/dist/web/esp32h2-LsKJE9AS.js +16 -0
  52. package/dist/web/esp32p4-7nWC-HiD.js +16 -0
  53. package/dist/web/esp32p4r3-CwiPecZW.js +16 -0
  54. package/dist/web/esp32s2-CtqVheSJ.js +16 -0
  55. package/dist/web/esp32s3-CRbtB0QR.js +16 -0
  56. package/dist/web/esp8266-nEkNAo8K.js +16 -0
  57. package/dist/web/index.js +7265 -0
  58. package/electron/main.js +333 -0
  59. package/electron/preload.js +37 -0
  60. package/eslint.config.js +22 -0
  61. package/index.html +408 -0
  62. package/js/modules/esp32-D955RjN9.js +16 -0
  63. package/js/modules/esp32c2-CJkxHDQi.js +16 -0
  64. package/js/modules/esp32c3-BhUHzH0o.js +16 -0
  65. package/js/modules/esp32c5-Chs0HtmA.js +16 -0
  66. package/js/modules/esp32c6-D6mPN6ut.js +16 -0
  67. package/js/modules/esp32c61-CQiYCWAs.js +16 -0
  68. package/js/modules/esp32h2-LsKJE9AS.js +16 -0
  69. package/js/modules/esp32p4-7nWC-HiD.js +16 -0
  70. package/js/modules/esp32p4r3-CwiPecZW.js +16 -0
  71. package/js/modules/esp32s2-CtqVheSJ.js +16 -0
  72. package/js/modules/esp32s3-CRbtB0QR.js +16 -0
  73. package/js/modules/esp8266-nEkNAo8K.js +16 -0
  74. package/js/modules/esptool.js +7265 -0
  75. package/js/script.js +2237 -0
  76. package/js/utilities.js +182 -0
  77. package/license.md +11 -0
  78. package/package.json +61 -0
  79. package/script/build +12 -0
  80. package/script/develop +17 -0
  81. package/src/const.ts +599 -0
  82. package/src/esp_loader.ts +1907 -0
  83. package/src/index.ts +63 -0
  84. package/src/lib/spiffs/index.ts +22 -0
  85. package/src/lib/spiffs/spiffs.ts +175 -0
  86. package/src/lib/spiffs/spiffsBlock.ts +204 -0
  87. package/src/lib/spiffs/spiffsConfig.ts +140 -0
  88. package/src/lib/spiffs/spiffsPage.ts +357 -0
  89. package/src/lib/spiffs/spiffsReader.ts +280 -0
  90. package/src/partition.ts +155 -0
  91. package/src/struct.ts +108 -0
  92. package/src/stubs/README.md +3 -0
  93. package/src/stubs/esp32.json +8 -0
  94. package/src/stubs/esp32c2.json +8 -0
  95. package/src/stubs/esp32c3.json +8 -0
  96. package/src/stubs/esp32c5.json +8 -0
  97. package/src/stubs/esp32c6.json +8 -0
  98. package/src/stubs/esp32c61.json +8 -0
  99. package/src/stubs/esp32h2.json +8 -0
  100. package/src/stubs/esp32p4.json +8 -0
  101. package/src/stubs/esp32p4r3.json +8 -0
  102. package/src/stubs/esp32s2.json +8 -0
  103. package/src/stubs/esp32s3.json +8 -0
  104. package/src/stubs/esp8266.json +8 -0
  105. package/src/stubs/index.ts +86 -0
  106. package/src/util.ts +49 -0
  107. package/src/wasm/fatfs/fatfs.wasm +0 -0
  108. package/src/wasm/fatfs/index.d.ts +26 -0
  109. package/src/wasm/fatfs/index.js +343 -0
  110. package/src/wasm/filesystems.ts +156 -0
  111. package/src/wasm/littlefs/index.d.ts +83 -0
  112. package/src/wasm/littlefs/index.js +529 -0
  113. package/src/wasm/littlefs/littlefs.js +2 -0
  114. package/src/wasm/littlefs/littlefs.wasm +0 -0
  115. package/src/wasm/shared/types.ts +13 -0
package/js/script.js ADDED
@@ -0,0 +1,2237 @@
1
+ let espStub;
2
+ let esp32s2ReconnectInProgress = false;
3
+ let currentLittleFS = null;
4
+ let currentLittleFSPartition = null;
5
+ let currentLittleFSPath = '/';
6
+ let currentLittleFSBlockSize = 4096;
7
+ let currentFilesystemType = null; // 'littlefs', 'fatfs', or 'spiffs'
8
+ let littlefsModulePromise = null; // Cache for LittleFS WASM module
9
+
10
+ /**
11
+ * Get display name for current filesystem type
12
+ */
13
+ function getFilesystemDisplayName() {
14
+ if (!currentFilesystemType) return 'Filesystem';
15
+ switch (currentFilesystemType) {
16
+ case 'littlefs': return 'LittleFS';
17
+ case 'fatfs': return 'FatFS';
18
+ case 'spiffs': return 'SPIFFS';
19
+ default: return 'Filesystem';
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Clear all cached data and state on disconnect
25
+ */
26
+ function clearAllCachedData() {
27
+ // Close filesystem if open
28
+ if (currentLittleFS) {
29
+ try {
30
+ // Only call destroy if it exists (LittleFS has it, FatFS/SPIFFS don't)
31
+ if (typeof currentLittleFS.destroy === 'function') {
32
+ currentLittleFS.destroy();
33
+ }
34
+ } catch (e) {
35
+ console.error('Error destroying filesystem:', e);
36
+ }
37
+ }
38
+
39
+ // Reset filesystem state
40
+ currentLittleFS = null;
41
+ currentLittleFSPartition = null;
42
+ currentLittleFSPath = '/';
43
+ currentLittleFSBlockSize = 4096;
44
+ currentFilesystemType = null;
45
+
46
+ // Hide filesystem manager
47
+ littlefsManager.classList.add('hidden');
48
+
49
+ // Clear partition list
50
+ partitionList.innerHTML = '';
51
+ partitionList.classList.add('hidden');
52
+
53
+ // Show the Read Partition Table button again
54
+ butReadPartitions.classList.remove('hidden');
55
+
56
+ // Clear file input
57
+ if (littlefsFileInput) {
58
+ littlefsFileInput.value = '';
59
+ }
60
+
61
+ // Reset buttons
62
+ butLittlefsUpload.disabled = true;
63
+
64
+ // Clear any cached module promises
65
+ littlefsModulePromise = null;
66
+
67
+ logMsg('All cached data cleared');
68
+ }
69
+
70
+ const baudRates = [2000000, 1500000, 921600, 500000, 460800, 230400, 153600, 128000, 115200];
71
+ const bufferSize = 512;
72
+ const colors = ["#00a7e9", "#f89521", "#be1e2d"];
73
+ const measurementPeriodId = "0001";
74
+
75
+ // Check if running in Electron
76
+ const isElectron = window.electronAPI && window.electronAPI.isElectron;
77
+
78
+ const maxLogLength = 100;
79
+ const log = document.getElementById("log");
80
+ const butConnect = document.getElementById("butConnect");
81
+ const baudRate = document.getElementById("baudRate");
82
+ const butClear = document.getElementById("butClear");
83
+ const butErase = document.getElementById("butErase");
84
+ const butProgram = document.getElementById("butProgram");
85
+ const butReadFlash = document.getElementById("butReadFlash");
86
+ const readOffset = document.getElementById("readOffset");
87
+ const readSize = document.getElementById("readSize");
88
+ const readProgress = document.getElementById("readProgress");
89
+ const butReadPartitions = document.getElementById("butReadPartitions");
90
+ const partitionList = document.getElementById("partitionList");
91
+ const littlefsManager = document.getElementById("littlefsManager");
92
+ const littlefsPartitionName = document.getElementById("littlefsPartitionName");
93
+ const littlefsPartitionSize = document.getElementById("littlefsPartitionSize");
94
+ const littlefsUsageBar = document.getElementById("littlefsUsageBar");
95
+ const littlefsUsageText = document.getElementById("littlefsUsageText");
96
+ const littlefsDiskVersion = document.getElementById("littlefsDiskVersion");
97
+ const littlefsFileList = document.getElementById("littlefsFileList");
98
+ const littlefsBreadcrumb = document.getElementById("littlefsBreadcrumb");
99
+ const butLittlefsUp = document.getElementById("butLittlefsUp");
100
+ const butLittlefsRefresh = document.getElementById("butLittlefsRefresh");
101
+ const butLittlefsBackup = document.getElementById("butLittlefsBackup");
102
+ const butLittlefsWrite = document.getElementById("butLittlefsWrite");
103
+ const butLittlefsClose = document.getElementById("butLittlefsClose");
104
+ const littlefsFileInput = document.getElementById("littlefsFileInput");
105
+ const butLittlefsUpload = document.getElementById("butLittlefsUpload");
106
+ const butLittlefsMkdir = document.getElementById("butLittlefsMkdir");
107
+ const autoscroll = document.getElementById("autoscroll");
108
+ const lightSS = document.getElementById("light");
109
+ const darkSS = document.getElementById("dark");
110
+ const darkMode = document.getElementById("darkmode");
111
+ const debugMode = document.getElementById("debugmode");
112
+ const showLog = document.getElementById("showlog");
113
+ const firmware = document.querySelectorAll(".upload .firmware input");
114
+ const progress = document.querySelectorAll(".upload .progress-bar");
115
+ const offsets = document.querySelectorAll(".upload .offset");
116
+ const appDiv = document.getElementById("app");
117
+
118
+ document.addEventListener("DOMContentLoaded", () => {
119
+ butConnect.addEventListener("click", () => {
120
+ clickConnect().catch(async (e) => {
121
+ console.error(e);
122
+ errorMsg(e.message || e);
123
+ if (espStub) {
124
+ await espStub.disconnect();
125
+ }
126
+ toggleUIConnected(false);
127
+ });
128
+ });
129
+ butClear.addEventListener("click", clickClear);
130
+ butErase.addEventListener("click", clickErase);
131
+ butProgram.addEventListener("click", clickProgram);
132
+ butReadFlash.addEventListener("click", clickReadFlash);
133
+ butReadPartitions.addEventListener("click", clickReadPartitions);
134
+ butLittlefsRefresh.addEventListener("click", clickLittlefsRefresh);
135
+ butLittlefsBackup.addEventListener("click", clickLittlefsBackup);
136
+ butLittlefsWrite.addEventListener("click", clickLittlefsWrite);
137
+ butLittlefsClose.addEventListener("click", clickLittlefsClose);
138
+ butLittlefsUp.addEventListener("click", clickLittlefsUp);
139
+ butLittlefsUpload.addEventListener("click", clickLittlefsUpload);
140
+ butLittlefsMkdir.addEventListener("click", clickLittlefsMkdir);
141
+ littlefsFileInput.addEventListener("change", () => {
142
+ butLittlefsUpload.disabled = !littlefsFileInput.files.length;
143
+ });
144
+ for (let i = 0; i < firmware.length; i++) {
145
+ firmware[i].addEventListener("change", checkFirmware);
146
+ }
147
+ for (let i = 0; i < offsets.length; i++) {
148
+ offsets[i].addEventListener("change", checkProgrammable);
149
+ }
150
+
151
+ // Initialize upload rows visibility - only show first row
152
+ updateUploadRowsVisibility();
153
+
154
+ autoscroll.addEventListener("click", clickAutoscroll);
155
+ baudRate.addEventListener("change", changeBaudRate);
156
+ darkMode.addEventListener("click", clickDarkMode);
157
+ debugMode.addEventListener("click", clickDebugMode);
158
+ showLog.addEventListener("click", clickShowLog);
159
+ window.addEventListener("error", function (event) {
160
+ console.log("Got an uncaught error: ", event.error);
161
+ });
162
+
163
+ // Header auto-hide functionality
164
+ const header = document.querySelector(".header");
165
+ const main = document.querySelector(".main");
166
+
167
+ // Show header on mouse enter at top of page
168
+ main.addEventListener("mousemove", (e) => {
169
+ if (e.clientY < 5 && header.classList.contains("header-hidden")) {
170
+ header.classList.remove("header-hidden");
171
+ main.classList.remove("no-header-padding");
172
+ }
173
+ });
174
+
175
+ // Keep header visible when mouse is over it
176
+ header.addEventListener("mouseenter", () => {
177
+ header.classList.remove("header-hidden");
178
+ main.classList.remove("no-header-padding");
179
+ });
180
+
181
+ // Hide header when mouse leaves (only if connected)
182
+ header.addEventListener("mouseleave", () => {
183
+ if (espStub && header.classList.contains("header-hidden") === false) {
184
+ setTimeout(() => {
185
+ if (!header.matches(":hover")) {
186
+ header.classList.add("header-hidden");
187
+ main.classList.add("no-header-padding");
188
+ }
189
+ }, 1000);
190
+ }
191
+ });
192
+
193
+ if ("serial" in navigator) {
194
+ const notSupported = document.getElementById("notSupported");
195
+ notSupported.classList.add("hidden");
196
+ }
197
+
198
+ initBaudRate();
199
+ loadAllSettings();
200
+ updateTheme();
201
+ logMsg("ESP32Tool loaded.");
202
+ });
203
+
204
+ function initBaudRate() {
205
+ for (let rate of baudRates) {
206
+ var option = document.createElement("option");
207
+ option.text = rate + " Baud";
208
+ option.value = rate;
209
+ baudRate.add(option);
210
+ }
211
+ }
212
+
213
+ function logMsg(text) {
214
+ log.innerHTML += text + "<br>";
215
+
216
+ // Remove old log content
217
+ if (log.textContent.split("\n").length > maxLogLength + 1) {
218
+ let logLines = log.innerHTML.replace(/(\n)/gm, "").split("<br>");
219
+ log.innerHTML = logLines.splice(-maxLogLength).join("<br>\n");
220
+ }
221
+
222
+ if (autoscroll.checked) {
223
+ log.scrollTop = log.scrollHeight;
224
+ }
225
+ }
226
+
227
+ function debugMsg(...args) {
228
+ if (!debugMode.checked) {
229
+ return;
230
+ }
231
+
232
+ function getStackTrace() {
233
+ let stack = new Error().stack;
234
+ //console.log(stack);
235
+ stack = stack.split("\n").map((v) => v.trim());
236
+ stack.shift();
237
+ stack.shift();
238
+
239
+ let trace = [];
240
+ for (let line of stack) {
241
+ line = line.replace("at ", "");
242
+ trace.push({
243
+ func: line.substr(0, line.indexOf("(") - 1),
244
+ pos: line.substring(line.indexOf(".js:") + 4, line.lastIndexOf(":")),
245
+ });
246
+ }
247
+
248
+ return trace;
249
+ }
250
+
251
+ let stack = getStackTrace();
252
+ stack.shift();
253
+ let top = stack.shift();
254
+ let prefix =
255
+ '<span class="debug-function">[' + top.func + ":" + top.pos + "]</span> ";
256
+ for (let arg of args) {
257
+ if (arg === undefined) {
258
+ logMsg(prefix + "undefined");
259
+ } else if (arg === null) {
260
+ logMsg(prefix + "null");
261
+ } else if (typeof arg == "string") {
262
+ logMsg(prefix + arg);
263
+ } else if (typeof arg == "number") {
264
+ logMsg(prefix + arg);
265
+ } else if (typeof arg == "boolean") {
266
+ logMsg(prefix + (arg ? "true" : "false"));
267
+ } else if (Array.isArray(arg)) {
268
+ logMsg(prefix + "[" + arg.map((value) => toHex(value)).join(", ") + "]");
269
+ } else if (typeof arg == "object" && arg instanceof Uint8Array) {
270
+ logMsg(
271
+ prefix +
272
+ "[" +
273
+ Array.from(arg)
274
+ .map((value) => toHex(value))
275
+ .join(", ") +
276
+ "]",
277
+ );
278
+ } else {
279
+ logMsg(prefix + "Unhandled type of argument:" + typeof arg);
280
+ console.log(arg);
281
+ }
282
+ prefix = ""; // Only show for first argument
283
+ }
284
+ }
285
+
286
+ function errorMsg(text) {
287
+ logMsg('<span class="error-message">Error:</span> ' + text);
288
+ console.error(text);
289
+ }
290
+
291
+ /**
292
+ * @name updateTheme
293
+ * Sets the theme to dark mode. Can be refactored later for more themes
294
+ */
295
+ function updateTheme() {
296
+ // Disable all themes
297
+ document
298
+ .querySelectorAll("link[rel=stylesheet].alternate")
299
+ .forEach((styleSheet) => {
300
+ enableStyleSheet(styleSheet, false);
301
+ });
302
+
303
+ if (darkMode.checked) {
304
+ enableStyleSheet(darkSS, true);
305
+ } else {
306
+ enableStyleSheet(lightSS, true);
307
+ }
308
+ }
309
+
310
+ function enableStyleSheet(node, enabled) {
311
+ node.disabled = !enabled;
312
+ }
313
+
314
+ function formatMacAddr(macAddr) {
315
+ return macAddr
316
+ .map((value) => value.toString(16).toUpperCase().padStart(2, "0"))
317
+ .join(":");
318
+ }
319
+
320
+ /**
321
+ * @name clickConnect
322
+ * Click handler for the connect/disconnect button.
323
+ */
324
+ async function clickConnect() {
325
+ if (espStub) {
326
+ // Remove disconnect event listener to prevent it from firing during manual disconnect
327
+ if (espStub.handleDisconnect) {
328
+ espStub.removeEventListener("disconnect", espStub.handleDisconnect);
329
+ }
330
+
331
+ await espStub.disconnect();
332
+ await espStub.port.close();
333
+ toggleUIConnected(false);
334
+ espStub = undefined;
335
+
336
+ // Clear all cached data and state
337
+ clearAllCachedData();
338
+
339
+ return;
340
+ }
341
+
342
+ const esploaderMod = await window.esptoolPackage;
343
+
344
+ let esploader = await esploaderMod.connect({
345
+ log: (...args) => logMsg(...args),
346
+ debug: (...args) => debugMsg(...args),
347
+ error: (...args) => errorMsg(...args),
348
+ });
349
+
350
+ // Store port info for ESP32-S2 detection
351
+ let portInfo = esploader.port?.getInfo ? esploader.port.getInfo() : {};
352
+ let isESP32S2 = portInfo.usbVendorId === 0x303a && portInfo.usbProductId === 0x0002;
353
+
354
+ // Handle ESP32-S2 Native USB reconnection requirement for BROWSER
355
+ // Only add listener if not already in reconnect mode and not in Electron
356
+ if (!esp32s2ReconnectInProgress && !isElectron) {
357
+ esploader.addEventListener("esp32s2-usb-reconnect", async () => {
358
+ // Prevent recursive calls
359
+ if (esp32s2ReconnectInProgress) {
360
+ return;
361
+ }
362
+
363
+ esp32s2ReconnectInProgress = true;
364
+ logMsg("ESP32-S2 Native USB detected!");
365
+ toggleUIConnected(false);
366
+ espStub = undefined;
367
+
368
+ try {
369
+ await esploader.port.close();
370
+
371
+ if (esploader.port.forget) {
372
+ await esploader.port.forget();
373
+ }
374
+ } catch (disconnectErr) {
375
+ // Ignore disconnect errors
376
+ }
377
+
378
+ // Show modal dialog
379
+ const modal = document.getElementById("esp32s2Modal");
380
+ const reconnectBtn = document.getElementById("butReconnectS2");
381
+
382
+ modal.classList.remove("hidden");
383
+
384
+ // Handle reconnect button click
385
+ const handleReconnect = async () => {
386
+ modal.classList.add("hidden");
387
+ reconnectBtn.removeEventListener("click", handleReconnect);
388
+
389
+ // Trigger port selection
390
+ try {
391
+ await clickConnect();
392
+ // Reset flag on successful connection
393
+ esp32s2ReconnectInProgress = false;
394
+ } catch (err) {
395
+ errorMsg("Failed to reconnect: " + err);
396
+ // Reset flag on error so user can try again
397
+ esp32s2ReconnectInProgress = false;
398
+ }
399
+ };
400
+
401
+ reconnectBtn.addEventListener("click", handleReconnect);
402
+ });
403
+ }
404
+
405
+ try {
406
+ await esploader.initialize();
407
+ } catch (err) {
408
+ // Check if this is an ESP32-S2 that needs reconnection
409
+ if (isESP32S2 && isElectron && !esp32s2ReconnectInProgress) {
410
+ esp32s2ReconnectInProgress = true;
411
+ logMsg("ESP32-S2 Native USB detected - automatic reconnection...");
412
+ toggleUIConnected(false);
413
+
414
+ try {
415
+ await esploader.port.close();
416
+ } catch (e) {
417
+ console.debug("Port close error:", e);
418
+ }
419
+
420
+ // Wait for new port to appear
421
+ logMsg("Waiting for ESP32-S2 CDC port...");
422
+
423
+ const waitForNewPort = new Promise((resolve) => {
424
+ const checkInterval = setInterval(() => {
425
+ if (navigator.serial && navigator.serial.getPorts) {
426
+ navigator.serial.getPorts().then(ports => {
427
+ if (ports.length > 0) {
428
+ clearInterval(checkInterval);
429
+ resolve(ports[0]);
430
+ }
431
+ });
432
+ }
433
+ }, 50);
434
+
435
+ // Timeout after 500 ms
436
+ setTimeout(() => {
437
+ clearInterval(checkInterval);
438
+ resolve(null);
439
+ }, 500);
440
+ });
441
+
442
+ const newPort = await waitForNewPort;
443
+
444
+ if (!newPort) {
445
+ esp32s2ReconnectInProgress = false;
446
+ throw new Error("ESP32-S2 CDC port did not appear in time");
447
+ }
448
+
449
+ // Additional small delay to ensure port is ready
450
+ await new Promise(resolve => setTimeout(resolve, 100));
451
+
452
+ // Open the new port and create ESPLoader directly
453
+ await newPort.open({ baudRate: 115200 });
454
+ logMsg("Connected successfully.");
455
+
456
+ esploader = new esploaderMod.ESPLoader(newPort, {
457
+ log: (...args) => logMsg(...args),
458
+ debug: (...args) => debugMsg(...args),
459
+ error: (...args) => errorMsg(...args),
460
+ });
461
+
462
+ // Initialize the new connection
463
+ await esploader.initialize();
464
+
465
+ esp32s2ReconnectInProgress = false;
466
+ logMsg("ESP32-S2 reconnection successful!");
467
+ } else {
468
+ // If ESP32-S2 reconnect is in progress (browser modal), suppress the error
469
+ if (esp32s2ReconnectInProgress) {
470
+ logMsg("Initialization interrupted for ESP32-S2 reconnection.");
471
+ return;
472
+ }
473
+
474
+ // Not ESP32-S2 or reconnect already attempted
475
+ try {
476
+ await esploader.disconnect();
477
+ } catch (disconnectErr) {
478
+ // Ignore disconnect errors
479
+ }
480
+ throw err;
481
+ }
482
+ }
483
+
484
+ logMsg("Connected to " + esploader.chipName);
485
+ logMsg("MAC Address: " + formatMacAddr(esploader.macAddr()));
486
+
487
+ espStub = await esploader.runStub();
488
+ toggleUIConnected(true);
489
+ toggleUIToolbar(true);
490
+
491
+ // Set detected flash size in the read size field
492
+ if (espStub.flashSize) {
493
+ const flashSizeBytes = parseInt(espStub.flashSize) * 1024 * 1024; // Convert MB to bytes
494
+ readSize.value = "0x" + flashSizeBytes.toString(16);
495
+ }
496
+
497
+ // Set the selected baud rate
498
+ let baud = parseInt(baudRate.value);
499
+ if (baudRates.includes(baud)) {
500
+ await espStub.setBaudrate(baud);
501
+ }
502
+
503
+ // Store disconnect handler so we can remove it later
504
+ const handleDisconnect = () => {
505
+ toggleUIConnected(false);
506
+ espStub = false;
507
+ };
508
+ espStub.handleDisconnect = handleDisconnect; // Store reference on espStub
509
+ espStub.addEventListener("disconnect", handleDisconnect);
510
+ }
511
+
512
+ /**
513
+ * @name changeBaudRate
514
+ * Change handler for the Baud Rate selector.
515
+ */
516
+ async function changeBaudRate() {
517
+ saveSetting("baudrate", baudRate.value);
518
+ if (espStub) {
519
+ let baud = parseInt(baudRate.value);
520
+ if (baudRates.includes(baud)) {
521
+ await espStub.setBaudrate(baud);
522
+ }
523
+ }
524
+ }
525
+
526
+ /**
527
+ * @name clickAutoscroll
528
+ * Change handler for the Autoscroll checkbox.
529
+ */
530
+ async function clickAutoscroll() {
531
+ saveSetting("autoscroll", autoscroll.checked);
532
+ }
533
+
534
+ /**
535
+ * @name clickDarkMode
536
+ * Change handler for the Dark Mode checkbox.
537
+ */
538
+ async function clickDarkMode() {
539
+ updateTheme();
540
+ saveSetting("darkmode", darkMode.checked);
541
+ }
542
+
543
+ /**
544
+ * @name clickDebugMode
545
+ * Change handler for the Debug Mode checkbox.
546
+ */
547
+ async function clickDebugMode() {
548
+ saveSetting("debugmode", debugMode.checked);
549
+ logMsg("Debug mode " + (debugMode.checked ? "enabled" : "disabled"));
550
+ }
551
+
552
+ /**
553
+ * @name clickShowLog
554
+ * Change handler for the Show Log checkbox.
555
+ */
556
+ async function clickShowLog() {
557
+ saveSetting("showlog", showLog.checked);
558
+ updateLogVisibility();
559
+ }
560
+
561
+ /**
562
+ * @name updateLogVisibility
563
+ * Update log and log controls visibility
564
+ */
565
+ function updateLogVisibility() {
566
+ const logControls = document.querySelector(".log-controls");
567
+
568
+ if (showLog.checked) {
569
+ log.classList.remove("hidden");
570
+ if (logControls) {
571
+ logControls.classList.remove("hidden");
572
+ }
573
+ } else {
574
+ log.classList.add("hidden");
575
+ if (logControls) {
576
+ logControls.classList.add("hidden");
577
+ }
578
+ }
579
+ }
580
+
581
+ /**
582
+ * @name clickErase
583
+ * Click handler for the erase button.
584
+ */
585
+ async function clickErase() {
586
+ let confirmed = false;
587
+
588
+ if (isElectron) {
589
+ confirmed = await window.electronAPI.showConfirm("This will erase the entire flash. Click OK to continue.");
590
+ } else {
591
+ confirmed = window.confirm("This will erase the entire flash. Click OK to continue.");
592
+ }
593
+
594
+ if (confirmed) {
595
+ baudRate.disabled = true;
596
+ butErase.disabled = true;
597
+ butProgram.disabled = true;
598
+ try {
599
+ logMsg("Erasing flash memory. Please wait...");
600
+ let stamp = Date.now();
601
+ await espStub.eraseFlash();
602
+ logMsg("Finished. Took " + (Date.now() - stamp) + "ms to erase.");
603
+ } catch (e) {
604
+ errorMsg(e);
605
+ } finally {
606
+ butErase.disabled = false;
607
+ baudRate.disabled = false;
608
+ butProgram.disabled = getValidFiles().length == 0;
609
+ }
610
+ }
611
+ }
612
+
613
+ /**
614
+ * @name clickProgram
615
+ * Click handler for the program button.
616
+ */
617
+ async function clickProgram() {
618
+ const readUploadedFileAsArrayBuffer = (inputFile) => {
619
+ const reader = new FileReader();
620
+
621
+ return new Promise((resolve, reject) => {
622
+ reader.onerror = () => {
623
+ reader.abort();
624
+ reject(new DOMException("Problem parsing input file."));
625
+ };
626
+
627
+ reader.onload = () => {
628
+ resolve(reader.result);
629
+ };
630
+ reader.readAsArrayBuffer(inputFile);
631
+ });
632
+ };
633
+
634
+ baudRate.disabled = true;
635
+ butErase.disabled = true;
636
+ butProgram.disabled = true;
637
+ for (let i = 0; i < firmware.length; i++) {
638
+ firmware[i].disabled = true;
639
+ offsets[i].disabled = true;
640
+ }
641
+ for (let file of getValidFiles()) {
642
+ progress[file].classList.remove("hidden");
643
+ let binfile = firmware[file].files[0];
644
+ let contents = await readUploadedFileAsArrayBuffer(binfile);
645
+ try {
646
+ let offset = parseInt(offsets[file].value, 16);
647
+ const progressBar = progress[file].querySelector("div");
648
+ await espStub.flashData(
649
+ contents,
650
+ (bytesWritten, totalBytes) => {
651
+ progressBar.style.width =
652
+ Math.floor((bytesWritten / totalBytes) * 100) + "%";
653
+ },
654
+ offset,
655
+ );
656
+ await sleep(100);
657
+ } catch (e) {
658
+ errorMsg(e);
659
+ }
660
+ }
661
+ for (let i = 0; i < firmware.length; i++) {
662
+ firmware[i].disabled = false;
663
+ offsets[i].disabled = false;
664
+ progress[i].classList.add("hidden");
665
+ progress[i].querySelector("div").style.width = "0";
666
+ }
667
+ butErase.disabled = false;
668
+ baudRate.disabled = false;
669
+ butProgram.disabled = getValidFiles().length == 0;
670
+ logMsg("To run the new firmware, please reset your device.");
671
+ }
672
+
673
+ function getValidFiles() {
674
+ // Get a list of file and offsets
675
+ // This will be used to check if we have valid stuff
676
+ // and will also return a list of files to program
677
+ let validFiles = [];
678
+ let offsetVals = [];
679
+ for (let i = 0; i < firmware.length; i++) {
680
+ let offs = parseInt(offsets[i].value, 16);
681
+ if (firmware[i].files.length > 0 && !offsetVals.includes(offs)) {
682
+ validFiles.push(i);
683
+ offsetVals.push(offs);
684
+ }
685
+ }
686
+ return validFiles;
687
+ }
688
+
689
+ /**
690
+ * @name checkProgrammable
691
+ * Check if the conditions to program the device are sufficient
692
+ */
693
+ async function checkProgrammable() {
694
+ butProgram.disabled = getValidFiles().length == 0;
695
+ }
696
+
697
+ /**
698
+ * @name checkFirmware
699
+ * Handler for firmware upload changes
700
+ */
701
+ async function checkFirmware(event) {
702
+ let filename = event.target.value.split("\\").pop();
703
+ let label = event.target.parentNode.querySelector("span");
704
+ let icon = event.target.parentNode.querySelector("svg");
705
+ if (filename != "") {
706
+ label.innerHTML = filename;
707
+ icon.classList.add("hidden");
708
+ } else {
709
+ label.innerHTML = "Choose a file&hellip;";
710
+ icon.classList.remove("hidden");
711
+ }
712
+
713
+ await checkProgrammable();
714
+ updateUploadRowsVisibility();
715
+ }
716
+
717
+ /**
718
+ * @name updateUploadRowsVisibility
719
+ * Show/hide upload rows dynamically - only for flash write section
720
+ */
721
+ function updateUploadRowsVisibility() {
722
+ const uploadRows = document.querySelectorAll(".upload");
723
+ let lastFilledIndex = -1;
724
+
725
+ // Find the last filled row
726
+ for (let i = 0; i < firmware.length; i++) {
727
+ if (firmware[i].files.length > 0) {
728
+ lastFilledIndex = i;
729
+ }
730
+ }
731
+
732
+ // Show rows up to lastFilledIndex + 1 (next empty row), minimum 1 row
733
+ for (let i = 0; i < uploadRows.length; i++) {
734
+ if (i <= lastFilledIndex + 1) {
735
+ uploadRows[i].style.display = "flex";
736
+ } else {
737
+ uploadRows[i].style.display = "none";
738
+ }
739
+ }
740
+ }
741
+
742
+ /**
743
+ * @name clickReadFlash
744
+ * Click handler for the read flash button.
745
+ */
746
+ async function clickReadFlash() {
747
+ const offset = parseInt(readOffset.value, 16);
748
+ const size = parseInt(readSize.value, 16);
749
+
750
+ if (isNaN(offset) || isNaN(size) || size <= 0) {
751
+ errorMsg("Invalid offset or size value");
752
+ return;
753
+ }
754
+
755
+ const defaultFilename = `flash_0x${offset.toString(16)}_0x${size.toString(16)}.bin`;
756
+
757
+ baudRate.disabled = true;
758
+ butErase.disabled = true;
759
+ butProgram.disabled = true;
760
+ butReadFlash.disabled = true;
761
+ readOffset.disabled = true;
762
+ readSize.disabled = true;
763
+ readProgress.classList.remove("hidden");
764
+
765
+ try {
766
+ const progressBar = readProgress.querySelector("div");
767
+
768
+ const data = await espStub.readFlash(
769
+ offset,
770
+ size,
771
+ (packet, progress, totalSize) => {
772
+ progressBar.style.width =
773
+ Math.floor((progress / totalSize) * 100) + "%";
774
+ }
775
+ );
776
+
777
+ logMsg(`Successfully read ${data.length} bytes from flash`);
778
+
779
+ // Save file using Electron API or browser download
780
+ await saveDataToFile(data, defaultFilename);
781
+
782
+ } catch (e) {
783
+ errorMsg("Failed to read flash: " + e);
784
+ } finally {
785
+ readProgress.classList.add("hidden");
786
+ readProgress.querySelector("div").style.width = "0";
787
+ butErase.disabled = false;
788
+ baudRate.disabled = false;
789
+ butProgram.disabled = getValidFiles().length == 0;
790
+ butReadFlash.disabled = false;
791
+ readOffset.disabled = false;
792
+ readSize.disabled = false;
793
+ }
794
+ }
795
+
796
+ /**
797
+ * @name clickReadPartitions
798
+ * Click handler for the read partitions button.
799
+ */
800
+ async function clickReadPartitions() {
801
+ const PARTITION_TABLE_OFFSET = 0x8000;
802
+ const PARTITION_TABLE_SIZE = 0x1000; // Read 4KB to get all partitions
803
+
804
+ butReadPartitions.disabled = true;
805
+ butErase.disabled = true;
806
+ butProgram.disabled = true;
807
+ butReadFlash.disabled = true;
808
+
809
+ try {
810
+ logMsg("Reading partition table from 0x8000...");
811
+
812
+ const data = await espStub.readFlash(PARTITION_TABLE_OFFSET, PARTITION_TABLE_SIZE);
813
+
814
+ const partitions = parsePartitionTable(data);
815
+
816
+ if (partitions.length === 0) {
817
+ errorMsg("No valid partition table found");
818
+ return;
819
+ }
820
+
821
+ logMsg(`Found ${partitions.length} partition(s)`);
822
+
823
+ // Display partitions
824
+ displayPartitions(partitions);
825
+
826
+ } catch (e) {
827
+ errorMsg("Failed to read partition table: " + e);
828
+ } finally {
829
+ butReadPartitions.disabled = false;
830
+ butErase.disabled = false;
831
+ butProgram.disabled = getValidFiles().length == 0;
832
+ butReadFlash.disabled = false;
833
+ }
834
+ }
835
+
836
+ /**
837
+ * Parse partition table from binary data
838
+ */
839
+ function parsePartitionTable(data) {
840
+ const PARTITION_MAGIC = 0x50aa;
841
+ const PARTITION_ENTRY_SIZE = 32;
842
+ const partitions = [];
843
+
844
+ for (let i = 0; i < data.length; i += PARTITION_ENTRY_SIZE) {
845
+ const magic = data[i] | (data[i + 1] << 8);
846
+
847
+ if (magic !== PARTITION_MAGIC) {
848
+ break; // End of partition table
849
+ }
850
+
851
+ const type = data[i + 2];
852
+ const subtype = data[i + 3];
853
+ const offset = data[i + 4] | (data[i + 5] << 8) | (data[i + 6] << 16) | (data[i + 7] << 24);
854
+ const size = data[i + 8] | (data[i + 9] << 8) | (data[i + 10] << 16) | (data[i + 11] << 24);
855
+
856
+ // Read name (16 bytes, null-terminated)
857
+ let name = "";
858
+ for (let j = 12; j < 28; j++) {
859
+ if (data[i + j] === 0) break;
860
+ name += String.fromCharCode(data[i + j]);
861
+ }
862
+
863
+ const flags = data[i + 28] | (data[i + 29] << 8) | (data[i + 30] << 16) | (data[i + 31] << 24);
864
+
865
+ // Get type names
866
+ const typeNames = { 0x00: "app", 0x01: "data" };
867
+ const appSubtypes = {
868
+ 0x00: "factory", 0x10: "ota_0", 0x11: "ota_1", 0x12: "ota_2",
869
+ 0x13: "ota_3", 0x14: "ota_4", 0x15: "ota_5", 0x20: "test"
870
+ };
871
+ const dataSubtypes = {
872
+ 0x00: "ota", 0x01: "phy", 0x02: "nvs", 0x03: "coredump",
873
+ 0x04: "nvs_keys", 0x05: "efuse", 0x81: "fat", 0x82: "spiffs"
874
+ };
875
+
876
+ const typeName = typeNames[type] || `0x${type.toString(16)}`;
877
+ let subtypeName = "";
878
+ if (type === 0x00) {
879
+ subtypeName = appSubtypes[subtype] || `0x${subtype.toString(16)}`;
880
+ } else if (type === 0x01) {
881
+ subtypeName = dataSubtypes[subtype] || `0x${subtype.toString(16)}`;
882
+ } else {
883
+ subtypeName = `0x${subtype.toString(16)}`;
884
+ }
885
+
886
+ partitions.push({
887
+ name,
888
+ type,
889
+ subtype,
890
+ offset,
891
+ size,
892
+ flags,
893
+ typeName,
894
+ subtypeName
895
+ });
896
+ }
897
+
898
+ return partitions;
899
+ }
900
+
901
+ /**
902
+ * Display partitions in the UI
903
+ */
904
+ function displayPartitions(partitions) {
905
+ partitionList.innerHTML = "";
906
+ partitionList.classList.remove("hidden");
907
+
908
+ // Hide the Read Partition Table button after successful read
909
+ butReadPartitions.classList.add("hidden");
910
+
911
+ const table = document.createElement("table");
912
+ table.className = "partition-table-display";
913
+
914
+ // Header
915
+ const thead = document.createElement("thead");
916
+ const headerRow = document.createElement("tr");
917
+ ["Name", "Type", "SubType", "Offset", "Size", "Action"].forEach(text => {
918
+ const th = document.createElement("th");
919
+ th.textContent = text;
920
+ headerRow.appendChild(th);
921
+ });
922
+ thead.appendChild(headerRow);
923
+ table.appendChild(thead);
924
+
925
+ // Body
926
+ const tbody = document.createElement("tbody");
927
+ partitions.forEach(partition => {
928
+ const row = document.createElement("tr");
929
+
930
+ // Name
931
+ const nameCell = document.createElement("td");
932
+ nameCell.textContent = partition.name;
933
+ row.appendChild(nameCell);
934
+
935
+ // Type
936
+ const typeCell = document.createElement("td");
937
+ typeCell.textContent = partition.typeName;
938
+ row.appendChild(typeCell);
939
+
940
+ // SubType
941
+ const subtypeCell = document.createElement("td");
942
+ subtypeCell.textContent = partition.subtypeName;
943
+ row.appendChild(subtypeCell);
944
+
945
+ // Offset
946
+ const offsetCell = document.createElement("td");
947
+ offsetCell.textContent = `0x${partition.offset.toString(16)}`;
948
+ row.appendChild(offsetCell);
949
+
950
+ // Size
951
+ const sizeCell = document.createElement("td");
952
+ sizeCell.textContent = formatSize(partition.size);
953
+ row.appendChild(sizeCell);
954
+
955
+ // Action
956
+ const actionCell = document.createElement("td");
957
+ const downloadBtn = document.createElement("button");
958
+ downloadBtn.textContent = "Download";
959
+ downloadBtn.className = "partition-download-btn";
960
+ downloadBtn.onclick = () => downloadPartition(partition);
961
+ actionCell.appendChild(downloadBtn);
962
+
963
+ // Add "Open FS" button for data partitions with filesystem
964
+ // 0x81 = FAT, 0x82 = SPIFFS (often contains LittleFS)
965
+ if (partition.type === 0x01 && (partition.subtype === 0x81 || partition.subtype === 0x82)) {
966
+ const fsBtn = document.createElement("button");
967
+ fsBtn.textContent = "Open FS";
968
+ fsBtn.className = "littlefs-fs-button";
969
+ fsBtn.onclick = () => openFilesystem(partition);
970
+ actionCell.appendChild(fsBtn);
971
+ }
972
+
973
+ row.appendChild(actionCell);
974
+
975
+ tbody.appendChild(row);
976
+ });
977
+ table.appendChild(tbody);
978
+
979
+ partitionList.appendChild(table);
980
+ }
981
+
982
+ /**
983
+ * Download a partition
984
+ */
985
+ async function downloadPartition(partition) {
986
+ const defaultFilename = `${partition.name}_0x${partition.offset.toString(16)}.bin`;
987
+
988
+ const partitionProgress = document.getElementById("partitionProgress");
989
+ const progressBar = partitionProgress.querySelector("div");
990
+
991
+ try {
992
+ partitionProgress.classList.remove("hidden");
993
+ progressBar.style.width = "0%";
994
+
995
+ logMsg(
996
+ `Downloading partition "${partition.name}" (${formatSize(partition.size)})...`
997
+ );
998
+
999
+ const data = await espStub.readFlash(
1000
+ partition.offset,
1001
+ partition.size,
1002
+ (packet, progress, totalSize) => {
1003
+ const percent = Math.floor((progress / totalSize) * 100);
1004
+ progressBar.style.width = percent + "%";
1005
+ }
1006
+ );
1007
+
1008
+ // Save file using Electron API or browser download
1009
+ await saveDataToFile(data, defaultFilename);
1010
+
1011
+ logMsg(`Partition "${partition.name}" downloaded successfully`);
1012
+ } catch (e) {
1013
+ errorMsg(`Failed to download partition: ${e}`);
1014
+ } finally {
1015
+ partitionProgress.classList.add("hidden");
1016
+ progressBar.style.width = "0%";
1017
+ }
1018
+ }
1019
+
1020
+ /**
1021
+ * Format size in human-readable format
1022
+ */
1023
+ function formatSize(bytes) {
1024
+ if (bytes < 1024) {
1025
+ return `${bytes} B`;
1026
+ } else if (bytes < 1024 * 1024) {
1027
+ return `${(bytes / 1024).toFixed(2)} KB`;
1028
+ } else {
1029
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
1030
+ }
1031
+ }
1032
+
1033
+ /**
1034
+ * @name clickClear
1035
+ * Click handler for the clear button.
1036
+ */
1037
+ async function clickClear() {
1038
+ // reset(); Reset function wasnt declared.
1039
+ log.innerHTML = "";
1040
+ }
1041
+
1042
+ function convertJSON(chunk) {
1043
+ try {
1044
+ let jsonObj = JSON.parse(chunk);
1045
+ return jsonObj;
1046
+ } catch (e) {
1047
+ return chunk;
1048
+ }
1049
+ }
1050
+
1051
+ function toggleUIToolbar(show) {
1052
+ isConnected = show;
1053
+ for (let i = 0; i < progress.length; i++) {
1054
+ progress[i].classList.add("hidden");
1055
+ progress[i].querySelector("div").style.width = "0";
1056
+ }
1057
+ if (show) {
1058
+ appDiv.classList.add("connected");
1059
+ } else {
1060
+ appDiv.classList.remove("connected");
1061
+ }
1062
+ butErase.disabled = !show;
1063
+ butReadFlash.disabled = !show;
1064
+ butReadPartitions.disabled = !show;
1065
+ }
1066
+
1067
+ function toggleUIConnected(connected) {
1068
+ let lbl = "Connect";
1069
+ const header = document.querySelector(".header");
1070
+ const main = document.querySelector(".main");
1071
+
1072
+ if (connected) {
1073
+ lbl = "Disconnect";
1074
+ // Auto-hide header after connection
1075
+ setTimeout(() => {
1076
+ header.classList.add("header-hidden");
1077
+ main.classList.add("no-header-padding");
1078
+ }, 2000); // Hide after 2 seconds
1079
+ } else {
1080
+ toggleUIToolbar(false);
1081
+ // Show header when disconnected
1082
+ header.classList.remove("header-hidden");
1083
+ main.classList.remove("no-header-padding");
1084
+ }
1085
+ butConnect.textContent = lbl;
1086
+ }
1087
+
1088
+ function loadAllSettings() {
1089
+ // Load all saved settings or defaults
1090
+ autoscroll.checked = loadSetting("autoscroll", true);
1091
+ baudRate.value = loadSetting("baudrate", 2000000);
1092
+ darkMode.checked = loadSetting("darkmode", false);
1093
+ debugMode.checked = loadSetting("debugmode", true);
1094
+ showLog.checked = loadSetting("showlog", false);
1095
+
1096
+ // Apply show log setting
1097
+ updateLogVisibility();
1098
+ }
1099
+
1100
+ function loadSetting(setting, defaultValue) {
1101
+ let value = JSON.parse(window.localStorage.getItem(setting));
1102
+ if (value == null) {
1103
+ return defaultValue;
1104
+ }
1105
+
1106
+ return value;
1107
+ }
1108
+
1109
+ function saveSetting(setting, value) {
1110
+ window.localStorage.setItem(setting, JSON.stringify(value));
1111
+ }
1112
+
1113
+ function ucWords(text) {
1114
+ return text
1115
+ .replace("_", " ")
1116
+ .toLowerCase()
1117
+ .replace(/(?<= )[^\s]|^./g, (a) => a.toUpperCase());
1118
+ }
1119
+
1120
+ function sleep(ms) {
1121
+ return new Promise((resolve) => setTimeout(resolve, ms));
1122
+ }
1123
+
1124
+ /**
1125
+ * Save data to file - uses Electron API in desktop app, browser download otherwise
1126
+ */
1127
+ async function saveDataToFile(data, defaultFilename) {
1128
+ if (isElectron) {
1129
+ // Use Electron's native save dialog
1130
+ const result = await window.electronAPI.saveFile(
1131
+ Array.from(data), // Convert Uint8Array to regular array for IPC
1132
+ defaultFilename
1133
+ );
1134
+
1135
+ if (result.success) {
1136
+ logMsg(`File saved: ${result.filePath}`);
1137
+ } else if (result.canceled) {
1138
+ logMsg("Save cancelled by user");
1139
+ } else {
1140
+ errorMsg(`Failed to save file: ${result.error}`);
1141
+ }
1142
+ } else {
1143
+ // Browser fallback - use download link
1144
+ const blob = new Blob([data], { type: "application/octet-stream" });
1145
+ const url = URL.createObjectURL(blob);
1146
+ const a = document.createElement("a");
1147
+ a.href = url;
1148
+ a.download = defaultFilename;
1149
+ document.body.appendChild(a);
1150
+ a.click();
1151
+ document.body.removeChild(a);
1152
+ URL.revokeObjectURL(url);
1153
+ logMsg(`Flash data downloaded as "${defaultFilename}"`);
1154
+ }
1155
+ }
1156
+
1157
+ /**
1158
+ * Read file from disk - uses Electron API in desktop app
1159
+ */
1160
+ async function readFileFromDisk() {
1161
+ if (isElectron) {
1162
+ const result = await window.electronAPI.openFile();
1163
+
1164
+ if (result.success) {
1165
+ return {
1166
+ data: new Uint8Array(result.data),
1167
+ filename: result.filename,
1168
+ filePath: result.filePath
1169
+ };
1170
+ } else if (result.canceled) {
1171
+ return null;
1172
+ } else {
1173
+ throw new Error(result.error);
1174
+ }
1175
+ }
1176
+ return null;
1177
+ }
1178
+
1179
+
1180
+ /**
1181
+ * Open and mount a filesystem partition
1182
+ */
1183
+ async function openFilesystem(partition) {
1184
+ try {
1185
+ logMsg(`Detecting filesystem type for partition "${partition.name}"...`);
1186
+
1187
+ // Detect filesystem type
1188
+ const fsType = await detectFilesystemType(partition.offset, partition.size);
1189
+ logMsg(`Detected filesystem: ${fsType}`);
1190
+
1191
+ if (fsType === 'littlefs') {
1192
+ await openLittleFS(partition);
1193
+ } else if (fsType === 'fatfs') {
1194
+ await openFatFS(partition);
1195
+ } else if (fsType === 'spiffs') {
1196
+ await openSPIFFS(partition);
1197
+ } else {
1198
+ errorMsg('Unknown filesystem type. Cannot open partition.');
1199
+ }
1200
+ } catch (e) {
1201
+ errorMsg(`Failed to open filesystem: ${e.message || e}`);
1202
+ }
1203
+ }
1204
+
1205
+ /**
1206
+ * Detect filesystem type by reading partition header
1207
+ *
1208
+ * LittleFS Detection:
1209
+ * - LittleFS stores metadata blocks at the beginning of the partition
1210
+ * - The superblock contains the string "littlefs" in its metadata
1211
+ * - LittleFS uses a specific block structure with magic numbers
1212
+ *
1213
+ * FatFS Detection:
1214
+ * - FAT boot sector signature 0xAA55 at offset 510-511
1215
+ * - FAT signature string at offset 54 (FAT16) or 82 (FAT32)
1216
+ *
1217
+ * SPIFFS Detection:
1218
+ * - SPIFFS has a different structure without the "littlefs" string
1219
+ * - SPIFFS uses object headers with different magic numbers
1220
+ *
1221
+ * Detection Strategy:
1222
+ * 1. Read first 8KB of partition (covers multiple blocks)
1223
+ * 2. Search for "littlefs" string in ASCII representation
1224
+ * 3. Check for FAT boot signature
1225
+ * 4. Check for SPIFFS magic
1226
+ * 5. If found -> corresponding FS, otherwise -> SPIFFS
1227
+ */
1228
+ async function detectFilesystemType(offset, size) {
1229
+ try {
1230
+ // Read first 8KB or entire partition if smaller
1231
+ const readSize = Math.min(8192, size);
1232
+ const data = await espStub.readFlash(offset, readSize);
1233
+
1234
+ if (data.length < 32) {
1235
+ logMsg('Partition too small, assuming SPIFFS');
1236
+ return 'spiffs';
1237
+ }
1238
+
1239
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
1240
+
1241
+ // Method 1: Check for SPIFFS magic number FIRST (most reliable)
1242
+ // SPIFFS magic: 0x20140529 XORed with page size and optionally (blocksLim - blockIndex)
1243
+ // The magic is stored as objIdLen bytes (typically 2 bytes) in the last lookup page
1244
+ // Pattern: Always ends with 0x05 in the second byte (from 0x0529)
1245
+
1246
+ // Check at block boundaries (every 4KB) for SPIFFS magic
1247
+ for (let blockOffset = 0; blockOffset < Math.min(8192, data.length); blockOffset += 4096) {
1248
+ // SPIFFS stores magic near the end of the last lookup page
1249
+ // For 4KB blocks, check around offset 0xF0-0xFF
1250
+ for (let pageOffset = 0xE0; pageOffset < Math.min(0x100, data.length - blockOffset - 2); pageOffset += 2) {
1251
+ const offset = blockOffset + pageOffset;
1252
+ if (offset + 2 > data.length) break;
1253
+
1254
+ // Read as 16-bit (objIdLen=2, most common)
1255
+ const magic16 = view.getUint16(offset, true);
1256
+
1257
+ // SPIFFS magic pattern: second byte is always 0x05 (from base 0x0529)
1258
+ // First byte varies based on XOR with pageSize and block index
1259
+ if ((magic16 & 0xFF00) === 0x0500) {
1260
+ logMsg(`SPIFFS detected: Found SPIFFS magic pattern 0x${magic16.toString(16)} at offset 0x${offset.toString(16)}`);
1261
+ return 'spiffs';
1262
+ }
1263
+ }
1264
+ }
1265
+
1266
+ // Method 2: Check for "littlefs" string in metadata (very reliable)
1267
+ // LittleFS stores this in the superblock metadata
1268
+ const decoder = new TextDecoder('ascii', { fatal: false });
1269
+ const dataStr = decoder.decode(data);
1270
+
1271
+ if (dataStr.includes('littlefs')) {
1272
+ logMsg('LittleFS detected: Found "littlefs" signature in partition data');
1273
+ return 'littlefs';
1274
+ }
1275
+
1276
+ // Method 3: Check for FAT filesystem signatures
1277
+ if (data.length >= 512) {
1278
+ const bootSig = view.getUint16(510, true);
1279
+
1280
+ if (bootSig === 0xAA55) {
1281
+ // Check for FAT signature strings
1282
+ let fat16Sig = '';
1283
+ let fat32Sig = '';
1284
+
1285
+ if (data.length >= 62) {
1286
+ fat16Sig = String.fromCharCode(data[54], data[55], data[56], data[57], data[58]);
1287
+ }
1288
+ if (data.length >= 90) {
1289
+ fat32Sig = String.fromCharCode(data[82], data[83], data[84], data[85], data[86]);
1290
+ }
1291
+
1292
+ if (fat16Sig.startsWith('FAT') || fat32Sig.startsWith('FAT')) {
1293
+ logMsg('FatFS detected: Found FAT boot signature and FAT string');
1294
+ return 'fatfs';
1295
+ } else {
1296
+ logMsg('Boot signature found but no FAT string - might be empty/unformatted FAT partition');
1297
+ }
1298
+ }
1299
+ }
1300
+
1301
+ // Method 4: Check for LittleFS magic numbers (more specific than structure check)
1302
+ // LittleFS v2.x magic: 0x32736c66 ("lfs2" in little-endian)
1303
+ // LittleFS v1.x magic: 0x31736c66 ("lfs1" in little-endian)
1304
+ for (let i = 0; i < Math.min(8192, data.length - 4); i++) {
1305
+ const magic32 = view.getUint32(i, true);
1306
+ if (magic32 === 0x32736c66 || magic32 === 0x31736c66) {
1307
+ logMsg('LittleFS detected: Found LittleFS magic number 0x' + magic32.toString(16) + ' at offset ' + i);
1308
+ return 'littlefs';
1309
+ }
1310
+ }
1311
+
1312
+ // Default: If no clear signature found, assume SPIFFS
1313
+ logMsg('No clear filesystem signature found, assuming SPIFFS');
1314
+ return 'spiffs';
1315
+
1316
+ } catch (err) {
1317
+ errorMsg(`Failed to detect filesystem type: ${err.message || err}`);
1318
+ return 'spiffs'; // Safe fallback
1319
+ }
1320
+ }
1321
+
1322
+ /**
1323
+ * Lazy-load and cache the LittleFS WASM module
1324
+ */
1325
+ async function loadLittlefsModule() {
1326
+ if (!littlefsModulePromise) {
1327
+ // Use absolute path from root for better compatibility with GitHub Pages
1328
+ const basePath = window.location.pathname.endsWith('/')
1329
+ ? window.location.pathname
1330
+ : window.location.pathname.substring(0, window.location.pathname.lastIndexOf('/') + 1);
1331
+ const modulePath = `${basePath}src/wasm/littlefs/index.js`;
1332
+
1333
+ littlefsModulePromise = import(modulePath)
1334
+ .catch(error => {
1335
+ console.error('Failed to load LittleFS module from:', modulePath, error);
1336
+ littlefsModulePromise = null; // Reset on error so it can be retried
1337
+ throw error;
1338
+ });
1339
+ }
1340
+ return littlefsModulePromise;
1341
+ }
1342
+
1343
+ /**
1344
+ * Reset LittleFS state
1345
+ */
1346
+ function resetLittleFSState() {
1347
+ // Clean up existing filesystem instance
1348
+ if (currentLittleFS) {
1349
+ try {
1350
+ // Don't call destroy() - it can cause crashes
1351
+ // Just let garbage collection handle it
1352
+ } catch (e) {
1353
+ console.error('Error cleaning up LittleFS:', e);
1354
+ }
1355
+ }
1356
+
1357
+ currentLittleFS = null;
1358
+ currentLittleFSPartition = null;
1359
+ currentLittleFSPath = '/';
1360
+ currentLittleFSBlockSize = 4096;
1361
+
1362
+ // Hide UI - safely check if elements exist
1363
+ try {
1364
+ if (littlefsManager) {
1365
+ littlefsManager.classList.add('hidden');
1366
+ }
1367
+
1368
+ // Clear file list
1369
+ if (littlefsFileList) {
1370
+ littlefsFileList.innerHTML = '';
1371
+ }
1372
+ } catch (e) {
1373
+ console.error('Error resetting LittleFS UI:', e);
1374
+ }
1375
+ }
1376
+
1377
+ /**
1378
+ * Open LittleFS partition
1379
+ */
1380
+ async function openLittleFS(partition) {
1381
+ try {
1382
+ logMsg(`Reading LittleFS partition "${partition.name}" (${formatSize(partition.size)})...`);
1383
+
1384
+ // Read entire partition
1385
+ const partitionProgress = document.getElementById("partitionProgress");
1386
+ const progressBar = partitionProgress.querySelector("div");
1387
+ partitionProgress.classList.remove("hidden");
1388
+
1389
+ const data = await espStub.readFlash(
1390
+ partition.offset,
1391
+ partition.size,
1392
+ (packet, progress, totalSize) => {
1393
+ const percent = Math.floor((progress / totalSize) * 100);
1394
+ progressBar.style.width = percent + "%";
1395
+ }
1396
+ );
1397
+
1398
+ partitionProgress.classList.add("hidden");
1399
+ progressBar.style.width = "0%";
1400
+
1401
+ logMsg('Mounting LittleFS filesystem...');
1402
+
1403
+ // Try to mount with different block sizes
1404
+ const blockSizes = [4096, 2048, 1024, 512];
1405
+ let fs = null;
1406
+ let blockSize = 0;
1407
+
1408
+ // Use cached module loader
1409
+ const module = await loadLittlefsModule();
1410
+ const { createLittleFSFromImage, formatDiskVersion } = module;
1411
+
1412
+ for (const bs of blockSizes) {
1413
+ try {
1414
+ const blockCount = Math.floor(partition.size / bs);
1415
+ fs = await createLittleFSFromImage(data, {
1416
+ blockSize: bs,
1417
+ blockCount: blockCount,
1418
+ });
1419
+
1420
+ // Try to list root to verify it works
1421
+ fs.list('/');
1422
+ blockSize = bs;
1423
+ logMsg(`Successfully mounted LittleFS with block size ${bs}`);
1424
+ break;
1425
+ } catch (err) {
1426
+ // Try next block size
1427
+ // Don't call destroy() - just let it be garbage collected
1428
+ fs = null;
1429
+ }
1430
+ }
1431
+
1432
+ if (!fs) {
1433
+ throw new Error('Failed to mount LittleFS with any block size');
1434
+ }
1435
+
1436
+ // Store filesystem instance
1437
+ currentLittleFS = fs;
1438
+ currentLittleFSPartition = partition;
1439
+ currentLittleFSPath = '/';
1440
+ currentLittleFSBlockSize = blockSize;
1441
+ currentFilesystemType = 'littlefs';
1442
+
1443
+ // Update UI
1444
+ littlefsPartitionName.textContent = partition.name;
1445
+ littlefsPartitionSize.textContent = formatSize(partition.size);
1446
+
1447
+ // Get disk version
1448
+ try {
1449
+ const diskVer = fs.getDiskVersion();
1450
+ const major = (diskVer >> 16) & 0xFFFF;
1451
+ const minor = diskVer & 0xFFFF;
1452
+ littlefsDiskVersion.textContent = `v${major}.${minor}`;
1453
+ } catch (e) {
1454
+ littlefsDiskVersion.textContent = '';
1455
+ }
1456
+
1457
+ // Show manager
1458
+ littlefsManager.classList.remove('hidden');
1459
+
1460
+ // Enable all operations for LittleFS (including directories)
1461
+ butLittlefsUpload.disabled = false;
1462
+ butLittlefsMkdir.disabled = false;
1463
+ butLittlefsWrite.disabled = false;
1464
+
1465
+ // Load files
1466
+ refreshLittleFS();
1467
+
1468
+ logMsg('LittleFS filesystem opened successfully');
1469
+ } catch (e) {
1470
+ errorMsg(`Failed to open LittleFS: ${e.message || e}`);
1471
+ // Don't call destroy() - just reset state
1472
+ resetLittleFSState();
1473
+ }
1474
+ }
1475
+
1476
+ /**
1477
+ * Open FatFS partition
1478
+ */
1479
+ async function openFatFS(partition) {
1480
+ try {
1481
+ logMsg(`Reading FatFS partition "${partition.name}" (${formatSize(partition.size)})...`);
1482
+
1483
+ // Read entire partition
1484
+ const partitionProgress = document.getElementById("partitionProgress");
1485
+ const progressBar = partitionProgress.querySelector("div");
1486
+ partitionProgress.classList.remove("hidden");
1487
+
1488
+ const data = await espStub.readFlash(
1489
+ partition.offset,
1490
+ partition.size,
1491
+ (packet, progress, totalSize) => {
1492
+ const percent = Math.floor((progress / totalSize) * 100);
1493
+ progressBar.style.width = percent + "%";
1494
+ }
1495
+ );
1496
+
1497
+ partitionProgress.classList.add("hidden");
1498
+ progressBar.style.width = "0%";
1499
+
1500
+ logMsg('Mounting FatFS filesystem...');
1501
+ logMsg(`Partition size: ${formatSize(partition.size)} (${partition.size} bytes)`);
1502
+
1503
+ // Load FatFS module
1504
+ const basePath = window.location.pathname.endsWith('/')
1505
+ ? window.location.pathname
1506
+ : window.location.pathname.substring(0, window.location.pathname.lastIndexOf('/') + 1);
1507
+ const modulePath = `${basePath}src/wasm/fatfs/index.js`;
1508
+ const module = await import(modulePath);
1509
+ const { createFatFSFromImage, createFatFS } = module;
1510
+
1511
+ // Use 4096 block size (ESP32 standard)
1512
+ let blockSize = 4096;
1513
+ let blockCount = Math.max(1, Math.floor(partition.size / blockSize));
1514
+ if (blockCount <= 0) {
1515
+ blockCount = 1;
1516
+ }
1517
+
1518
+ let fs = null;
1519
+
1520
+ // First try to mount existing FatFS from image
1521
+ try {
1522
+ logMsg(`Trying to mount FatFS with block size ${blockSize} (${blockCount} blocks)...`);
1523
+
1524
+ fs = await createFatFSFromImage(data, {
1525
+ blockSize: blockSize,
1526
+ blockCount: blockCount,
1527
+ });
1528
+
1529
+ logMsg(`FatFS instance created, attempting to list files...`);
1530
+ const files = fs.list();
1531
+ logMsg(`Successfully listed ${files.length} files/directories`);
1532
+ logMsg(`Successfully mounted FatFS`);
1533
+ } catch (err) {
1534
+ logMsg(`Failed to mount existing FatFS: ${err.message || err}`);
1535
+
1536
+ // If mounting fails, create a new empty formatted filesystem
1537
+ // Note: This does NOT use the image data - it creates a blank filesystem
1538
+ if (createFatFS) {
1539
+ try {
1540
+ logMsg(`Creating new blank FatFS (not using image data)...`);
1541
+ fs = await createFatFS({
1542
+ blockSize: blockSize,
1543
+ blockCount: blockCount,
1544
+ formatOnInit: true,
1545
+ });
1546
+ logMsg(`Created new formatted FatFS`);
1547
+ logMsg(`Partition appears blank/unformatted. You can format and save to initialize it.`);
1548
+ } catch (createErr) {
1549
+ logMsg(`Failed to create new FatFS: ${createErr.message || createErr}`);
1550
+ throw err; // Throw original error
1551
+ }
1552
+ } else {
1553
+ throw err;
1554
+ }
1555
+ }
1556
+
1557
+ if (!fs) {
1558
+ throw new Error('Failed to mount FatFS with any block size. The partition may not contain a valid FAT filesystem or may be corrupted.');
1559
+ }
1560
+
1561
+ // Store filesystem instance and block size
1562
+ currentLittleFS = fs;
1563
+ currentLittleFSPartition = partition;
1564
+ currentLittleFSPath = '/';
1565
+ currentLittleFSBlockSize = blockSize;
1566
+ currentFilesystemType = 'fatfs';
1567
+
1568
+ // Update UI
1569
+ littlefsPartitionName.textContent = partition.name;
1570
+ littlefsPartitionSize.textContent = formatSize(partition.size);
1571
+ littlefsDiskVersion.textContent = 'FAT';
1572
+
1573
+ // Show manager
1574
+ littlefsManager.classList.remove('hidden');
1575
+
1576
+ // Enable all operations for FatFS (including directories)
1577
+ butLittlefsUpload.disabled = false;
1578
+ butLittlefsMkdir.disabled = false;
1579
+ butLittlefsWrite.disabled = false;
1580
+
1581
+ // Load files
1582
+ refreshLittleFS();
1583
+
1584
+ logMsg('FatFS filesystem opened successfully');
1585
+ } catch (e) {
1586
+ errorMsg(`Failed to open FatFS: ${e.message || e}`);
1587
+ console.error('FatFS open error:', e);
1588
+ resetLittleFSState();
1589
+ }
1590
+ }
1591
+
1592
+ /**
1593
+ * Open SPIFFS partition
1594
+ */
1595
+ async function openSPIFFS(partition) {
1596
+ try {
1597
+ logMsg(`Reading SPIFFS partition "${partition.name}" (${formatSize(partition.size)})...`);
1598
+
1599
+ // Read entire partition
1600
+ const partitionProgress = document.getElementById("partitionProgress");
1601
+ const progressBar = partitionProgress.querySelector("div");
1602
+ partitionProgress.classList.remove("hidden");
1603
+
1604
+ const data = await espStub.readFlash(
1605
+ partition.offset,
1606
+ partition.size,
1607
+ (packet, progress, totalSize) => {
1608
+ const percent = Math.floor((progress / totalSize) * 100);
1609
+ progressBar.style.width = percent + "%";
1610
+ }
1611
+ );
1612
+
1613
+ partitionProgress.classList.add("hidden");
1614
+ progressBar.style.width = "0%";
1615
+
1616
+ logMsg('Parsing SPIFFS filesystem...');
1617
+ logMsg(`Partition size: ${formatSize(partition.size)} (${partition.size} bytes)`);
1618
+
1619
+ // Import SPIFFS module
1620
+ const basePath = window.location.pathname.endsWith('/')
1621
+ ? window.location.pathname
1622
+ : window.location.pathname.substring(0, window.location.pathname.lastIndexOf('/') + 1);
1623
+ const modulePath = `${basePath}js/modules/esptool.js`;
1624
+
1625
+ const { SpiffsFS, SpiffsReader, SpiffsBuildConfig, DEFAULT_SPIFFS_CONFIG } = await import(modulePath);
1626
+
1627
+ // Create build config with partition size
1628
+ const config = new SpiffsBuildConfig({
1629
+ ...DEFAULT_SPIFFS_CONFIG,
1630
+ imgSize: partition.size,
1631
+ });
1632
+
1633
+ // Create reader and parse existing files
1634
+ const reader = new SpiffsReader(data, config);
1635
+ reader.parse();
1636
+
1637
+ // Get file list
1638
+ const files = reader.listFiles();
1639
+ logMsg(`Found ${files.length} files in SPIFFS`);
1640
+
1641
+ // Create a wrapper object that mimics LittleFS interface with full read/write support
1642
+ const spiffsWrapper = {
1643
+ _reader: reader,
1644
+ _files: files,
1645
+ _partition: partition,
1646
+ _config: config,
1647
+ _originalData: data, // Store original image data
1648
+ _modified: false,
1649
+
1650
+ list: function(path = '/') {
1651
+ // Normalize path
1652
+ const normalizedPath = path === '/' ? '' : path.replace(/^\//, '').replace(/\/$/, '');
1653
+
1654
+ // Get all files with proper path property for UI compatibility
1655
+ const allFiles = this._files.map(f => {
1656
+ const fileName = f.name.startsWith('/') ? f.name.substring(1) : f.name;
1657
+ return {
1658
+ name: fileName,
1659
+ path: '/' + fileName, // Add path property for UI
1660
+ type: 'file',
1661
+ size: f.size,
1662
+ _data: f.data
1663
+ };
1664
+ });
1665
+
1666
+ // If root, return all files
1667
+ if (!normalizedPath) {
1668
+ return allFiles;
1669
+ }
1670
+
1671
+ // Filter by path prefix
1672
+ const prefix = normalizedPath + '/';
1673
+ return allFiles.filter(f => f.name.startsWith(prefix));
1674
+ },
1675
+
1676
+ read: function(path) {
1677
+ const normalizedPath = path.startsWith('/') ? path.substring(1) : path;
1678
+ const file = this._files.find(f => {
1679
+ const fname = f.name.startsWith('/') ? f.name.substring(1) : f.name;
1680
+ return fname === normalizedPath;
1681
+ });
1682
+ return file ? file.data : null;
1683
+ },
1684
+
1685
+ readFile: function(path) {
1686
+ // Alias for read() to match LittleFS interface
1687
+ return this.read(path);
1688
+ },
1689
+
1690
+ write: function(path, data) {
1691
+ // Determine the filename format used in original files
1692
+ // Check if original files have leading slash
1693
+ const hasLeadingSlash = this._files.length > 0 && this._files[0].name.startsWith('/');
1694
+
1695
+ // Normalize path for comparison
1696
+ const normalizedPath = path.startsWith('/') ? path.substring(1) : path;
1697
+
1698
+ // Store filename in the same format as original files
1699
+ const storedName = hasLeadingSlash ? '/' + normalizedPath : normalizedPath;
1700
+
1701
+ // Check if file already exists
1702
+ const existingIndex = this._files.findIndex(f => {
1703
+ const fname = f.name.startsWith('/') ? f.name.substring(1) : f.name;
1704
+ return fname === normalizedPath;
1705
+ });
1706
+
1707
+ // Update or add file
1708
+ if (existingIndex >= 0) {
1709
+ this._files[existingIndex] = {
1710
+ name: storedName,
1711
+ size: data.length,
1712
+ data: data
1713
+ };
1714
+ } else {
1715
+ this._files.push({
1716
+ name: storedName,
1717
+ size: data.length,
1718
+ data: data
1719
+ });
1720
+ }
1721
+
1722
+ this._modified = true;
1723
+ },
1724
+
1725
+ writeFile: function(path, data) {
1726
+ // Alias for write() to match LittleFS interface
1727
+ return this.write(path, data);
1728
+ },
1729
+
1730
+ addFile: function(path, data) {
1731
+ // Alias for write() to match alternative interface
1732
+ return this.write(path, data);
1733
+ },
1734
+
1735
+ remove: function(path) {
1736
+ // Normalize path
1737
+ const normalizedPath = path.startsWith('/') ? path.substring(1) : path;
1738
+
1739
+ // Find and remove file
1740
+ const index = this._files.findIndex(f => {
1741
+ const fname = f.name.startsWith('/') ? f.name.substring(1) : f.name;
1742
+ return fname === normalizedPath;
1743
+ });
1744
+
1745
+ if (index >= 0) {
1746
+ this._files.splice(index, 1);
1747
+ this._modified = true;
1748
+ } else {
1749
+ throw new Error(`File not found: ${path}`);
1750
+ }
1751
+ },
1752
+
1753
+ deleteFile: function(path) {
1754
+ // Alias for remove() to match LittleFS interface
1755
+ return this.remove(path);
1756
+ },
1757
+
1758
+ delete: function(path, options) {
1759
+ // For compatibility with LittleFS delete method
1760
+ // SPIFFS doesn't have directories, so just delete the file
1761
+ return this.remove(path);
1762
+ },
1763
+
1764
+ mkdir: function() {
1765
+ throw new Error('SPIFFS does not support directories. Files are stored in a flat structure.');
1766
+ },
1767
+
1768
+ toImage: function() {
1769
+ // If not modified, return original data
1770
+ if (!this._modified) {
1771
+ return this._originalData || new Uint8Array(this._partition.size);
1772
+ }
1773
+
1774
+ // Create new SPIFFS filesystem with all files
1775
+ const fs = new SpiffsFS(this._partition.size, this._config);
1776
+
1777
+ // Add all files - preserve original filename format
1778
+ for (const file of this._files) {
1779
+ // Use the filename exactly as stored in _files
1780
+ // This preserves whether it has a leading slash or not
1781
+ const fileName = file.name;
1782
+
1783
+ // Log for debugging
1784
+ console.log(`Adding file to SPIFFS: "${fileName}" (${file.data.length} bytes)`);
1785
+
1786
+ fs.createFile(fileName, file.data);
1787
+ }
1788
+
1789
+ // Generate binary image
1790
+ const image = fs.toBinary();
1791
+ console.log(`Generated SPIFFS image: ${image.length} bytes`);
1792
+ return image;
1793
+ }
1794
+ };
1795
+
1796
+ // Store filesystem instance
1797
+ currentLittleFS = spiffsWrapper;
1798
+ currentLittleFSPartition = partition;
1799
+ currentLittleFSPath = '/';
1800
+ currentLittleFSBlockSize = config.blockSize;
1801
+ currentFilesystemType = 'spiffs';
1802
+
1803
+ // Update UI
1804
+ littlefsPartitionName.textContent = partition.name;
1805
+ littlefsPartitionSize.textContent = formatSize(partition.size);
1806
+ littlefsDiskVersion.textContent = 'SPIFFS';
1807
+
1808
+ // Show manager
1809
+ littlefsManager.classList.remove('hidden');
1810
+
1811
+ // Enable write operations for SPIFFS (but not mkdir since SPIFFS is flat)
1812
+ butLittlefsUpload.disabled = false;
1813
+ butLittlefsMkdir.disabled = true; // SPIFFS doesn't support directories
1814
+ butLittlefsWrite.disabled = false;
1815
+
1816
+ // Load files
1817
+ refreshLittleFS();
1818
+
1819
+ logMsg('SPIFFS filesystem opened successfully');
1820
+ logMsg('Note: SPIFFS is a flat filesystem - directories are not supported.');
1821
+ } catch (e) {
1822
+ errorMsg(`Failed to open SPIFFS: ${e.message || e}`);
1823
+ console.error('SPIFFS open error:', e);
1824
+ resetLittleFSState();
1825
+ }
1826
+ }
1827
+
1828
+ /**
1829
+ * Estimate LittleFS storage footprint for a single file (data + metadata block)
1830
+ */
1831
+ function littlefsEstimateFileFootprint(size) {
1832
+ const block = currentLittleFSBlockSize || 4096;
1833
+ const dataBytes = Math.max(1, Math.ceil(size / block)) * block;
1834
+ const metadataBytes = block; // per-file metadata block
1835
+ return dataBytes + metadataBytes;
1836
+ }
1837
+
1838
+ /**
1839
+ * Estimate total LittleFS usage for a set of entries
1840
+ */
1841
+ function littlefsEstimateUsage(entries) {
1842
+ const block = currentLittleFSBlockSize || 4096;
1843
+ let total = block * 2; // root metadata copies
1844
+
1845
+ for (const entry of entries || []) {
1846
+ if (entry.type === 'dir') {
1847
+ total += block;
1848
+ } else {
1849
+ total += littlefsEstimateFileFootprint(entry.size || 0);
1850
+ }
1851
+ }
1852
+
1853
+ return total;
1854
+ }
1855
+
1856
+ /**
1857
+ * Refresh LittleFS file list
1858
+ */
1859
+ function refreshLittleFS() {
1860
+ if (!currentLittleFS) return;
1861
+
1862
+ try {
1863
+ // Calculate usage based on all files (like ESPConnect)
1864
+ const allFiles = currentLittleFS.list('/');
1865
+ const usedBytes = littlefsEstimateUsage(allFiles);
1866
+ const totalBytes = currentLittleFSPartition.size;
1867
+ const usedPercent = Math.round((usedBytes / totalBytes) * 100);
1868
+
1869
+ littlefsUsageBar.style.width = usedPercent + '%';
1870
+ littlefsUsageText.textContent = `Used: ${formatSize(usedBytes)} / ${formatSize(totalBytes)} (${usedPercent}%)`;
1871
+
1872
+ // Update breadcrumb
1873
+ littlefsBreadcrumb.textContent = currentLittleFSPath || '/';
1874
+ butLittlefsUp.disabled = currentLittleFSPath === '/' || !currentLittleFSPath;
1875
+
1876
+ // List files
1877
+ const entries = currentLittleFS.list(currentLittleFSPath);
1878
+
1879
+ // Clear table
1880
+ littlefsFileList.innerHTML = '';
1881
+
1882
+ if (entries.length === 0) {
1883
+ const row = document.createElement('tr');
1884
+ row.innerHTML = '<td colspan="4" class="empty-state">No files in this directory</td>';
1885
+ littlefsFileList.appendChild(row);
1886
+ return;
1887
+ }
1888
+
1889
+ // Sort: directories first, then files
1890
+ entries.sort((a, b) => {
1891
+ if (a.type === 'dir' && b.type !== 'dir') return -1;
1892
+ if (a.type !== 'dir' && b.type === 'dir') return 1;
1893
+ return a.path.localeCompare(b.path);
1894
+ });
1895
+
1896
+ // Add rows
1897
+ entries.forEach(entry => {
1898
+ const row = document.createElement('tr');
1899
+
1900
+ // Name
1901
+ const nameCell = document.createElement('td');
1902
+ const nameDiv = document.createElement('div');
1903
+ nameDiv.className = 'file-name' + (entry.type === 'dir' ? ' clickable' : '');
1904
+
1905
+ const icon = document.createElement('span');
1906
+ icon.className = 'file-icon';
1907
+ icon.textContent = entry.type === 'dir' ? '📁' : '📄';
1908
+
1909
+ const name = entry.path.split('/').filter(Boolean).pop() || '/';
1910
+ const nameText = document.createElement('span');
1911
+ nameText.textContent = name;
1912
+
1913
+ nameDiv.appendChild(icon);
1914
+ nameDiv.appendChild(nameText);
1915
+
1916
+ if (entry.type === 'dir') {
1917
+ nameDiv.onclick = () => navigateLittleFS(entry.path);
1918
+ }
1919
+
1920
+ nameCell.appendChild(nameDiv);
1921
+ row.appendChild(nameCell);
1922
+
1923
+ // Type
1924
+ const typeCell = document.createElement('td');
1925
+ typeCell.textContent = entry.type === 'dir' ? 'Directory' : 'File';
1926
+ row.appendChild(typeCell);
1927
+
1928
+ // Size
1929
+ const sizeCell = document.createElement('td');
1930
+ sizeCell.textContent = entry.type === 'file' ? formatSize(entry.size) : '-';
1931
+ row.appendChild(sizeCell);
1932
+
1933
+ // Actions
1934
+ const actionsCell = document.createElement('td');
1935
+ const actionsDiv = document.createElement('div');
1936
+ actionsDiv.className = 'file-actions';
1937
+
1938
+ if (entry.type === 'file') {
1939
+ const downloadBtn = document.createElement('button');
1940
+ downloadBtn.textContent = 'Download';
1941
+ downloadBtn.onclick = () => downloadLittleFSFile(entry.path);
1942
+ actionsDiv.appendChild(downloadBtn);
1943
+ }
1944
+
1945
+ const deleteBtn = document.createElement('button');
1946
+ deleteBtn.textContent = 'Delete';
1947
+ deleteBtn.className = 'delete-btn';
1948
+ deleteBtn.onclick = () => deleteLittleFSFile(entry.path, entry.type);
1949
+ actionsDiv.appendChild(deleteBtn);
1950
+
1951
+ actionsCell.appendChild(actionsDiv);
1952
+ row.appendChild(actionsCell);
1953
+
1954
+ littlefsFileList.appendChild(row);
1955
+ });
1956
+ } catch (e) {
1957
+ errorMsg(`Failed to refresh file list: ${e.message || e}`);
1958
+ }
1959
+ }
1960
+
1961
+ /**
1962
+ * Navigate to a directory in LittleFS
1963
+ */
1964
+ function navigateLittleFS(path) {
1965
+ currentLittleFSPath = path;
1966
+ refreshLittleFS();
1967
+ }
1968
+
1969
+ /**
1970
+ * Navigate up one directory
1971
+ */
1972
+ function clickLittlefsUp() {
1973
+ if (currentLittleFSPath === '/' || !currentLittleFSPath) return;
1974
+
1975
+ const parts = currentLittleFSPath.split('/').filter(Boolean);
1976
+ parts.pop();
1977
+ currentLittleFSPath = '/' + parts.join('/');
1978
+ if (currentLittleFSPath !== '/' && !currentLittleFSPath.endsWith('/')) {
1979
+ currentLittleFSPath += '/';
1980
+ }
1981
+ refreshLittleFS();
1982
+ }
1983
+
1984
+ /**
1985
+ * Refresh button handler
1986
+ */
1987
+ function clickLittlefsRefresh() {
1988
+ refreshLittleFS();
1989
+ logMsg(`${getFilesystemDisplayName()} file list refreshed`);
1990
+ }
1991
+
1992
+ /**
1993
+ * Backup LittleFS image
1994
+ */
1995
+ async function clickLittlefsBackup() {
1996
+ if (!currentLittleFS || !currentLittleFSPartition) return;
1997
+
1998
+ try {
1999
+ logMsg(`Creating ${getFilesystemDisplayName()} backup image...`);
2000
+ const image = currentLittleFS.toImage();
2001
+
2002
+ const fsType = currentFilesystemType || 'filesystem';
2003
+ const filename = `${currentLittleFSPartition.name}_${fsType}_backup.bin`;
2004
+ await saveDataToFile(image, filename);
2005
+
2006
+ logMsg(`${getFilesystemDisplayName()} backup saved as "${filename}"`);
2007
+ } catch (e) {
2008
+ errorMsg(`Failed to backup ${getFilesystemDisplayName()}: ${e.message || e}`);
2009
+ }
2010
+ }
2011
+
2012
+ /**
2013
+ * Write LittleFS image to flash
2014
+ */
2015
+ async function clickLittlefsWrite() {
2016
+ if (!currentLittleFS || !currentLittleFSPartition) return;
2017
+
2018
+ const confirmed = confirm(
2019
+ `Write modified filesystem to flash?\n\n` +
2020
+ `Partition: ${currentLittleFSPartition.name}\n` +
2021
+ `Offset: 0x${currentLittleFSPartition.offset.toString(16)}\n` +
2022
+ `Size: ${formatSize(currentLittleFSPartition.size)}\n\n` +
2023
+ `This will overwrite the current filesystem on the device!`
2024
+ );
2025
+
2026
+ if (!confirmed) return;
2027
+
2028
+ try {
2029
+ logMsg(`Creating ${getFilesystemDisplayName()} image...`);
2030
+ const image = currentLittleFS.toImage();
2031
+ logMsg(`Image created: ${formatSize(image.length)}`);
2032
+
2033
+ if (image.length > currentLittleFSPartition.size) {
2034
+ errorMsg(`Image size (${formatSize(image.length)}) exceeds partition size (${formatSize(currentLittleFSPartition.size)})`);
2035
+ return;
2036
+ }
2037
+
2038
+ // Disable buttons during write
2039
+ butLittlefsRefresh.disabled = true;
2040
+ butLittlefsBackup.disabled = true;
2041
+ butLittlefsWrite.disabled = true;
2042
+ butLittlefsClose.disabled = true;
2043
+ butLittlefsUpload.disabled = true;
2044
+ butLittlefsMkdir.disabled = true;
2045
+
2046
+ logMsg(`Writing ${formatSize(image.length)} to partition "${currentLittleFSPartition.name}" at 0x${currentLittleFSPartition.offset.toString(16)}...`);
2047
+
2048
+ // Use the LittleFS usage bar as progress indicator
2049
+ const usageBar = document.getElementById("littlefsUsageBar");
2050
+ const usageText = document.getElementById("littlefsUsageText");
2051
+ const originalUsageBarWidth = usageBar.style.width;
2052
+ const originalUsageText = usageText.textContent;
2053
+
2054
+ // Convert Uint8Array to ArrayBuffer (CRITICAL: flashData expects ArrayBuffer, not Uint8Array)
2055
+ // This matches the ESPConnect implementation
2056
+ const imageBuffer = image.buffer.slice(image.byteOffset, image.byteOffset + image.byteLength);
2057
+
2058
+ // Write the image to flash with progress indication
2059
+ await espStub.flashData(
2060
+ imageBuffer,
2061
+ (bytesWritten, totalBytes) => {
2062
+ const percent = Math.floor((bytesWritten / totalBytes) * 100);
2063
+ usageBar.style.width = percent + "%";
2064
+ usageText.textContent = `Writing: ${formatSize(bytesWritten)} / ${formatSize(totalBytes)} (${percent}%)`;
2065
+ },
2066
+ currentLittleFSPartition.offset
2067
+ );
2068
+
2069
+ // Restore original usage display
2070
+ usageBar.style.width = originalUsageBarWidth;
2071
+ usageText.textContent = originalUsageText;
2072
+
2073
+ logMsg(`${getFilesystemDisplayName()} successfully written to flash!`);
2074
+ logMsg(`To use the new filesystem, reset your device.`);
2075
+
2076
+ } catch (e) {
2077
+ errorMsg(`Failed to write ${getFilesystemDisplayName()} to flash: ${e.message || e}`);
2078
+ } finally {
2079
+ // Re-enable buttons
2080
+ butLittlefsRefresh.disabled = false;
2081
+ butLittlefsBackup.disabled = false;
2082
+ butLittlefsWrite.disabled = false;
2083
+ butLittlefsClose.disabled = false;
2084
+ butLittlefsUpload.disabled = !littlefsFileInput.files.length;
2085
+ butLittlefsMkdir.disabled = false;
2086
+ }
2087
+ }
2088
+
2089
+ /**
2090
+ * Close LittleFS manager
2091
+ */
2092
+ function clickLittlefsClose() {
2093
+ const fsName = getFilesystemDisplayName() || 'Filesystem';
2094
+
2095
+ if (currentLittleFS) {
2096
+ try {
2097
+ // Only call destroy if it exists (LittleFS has it, FatFS/SPIFFS don't)
2098
+ if (typeof currentLittleFS.destroy === 'function') {
2099
+ currentLittleFS.destroy();
2100
+ }
2101
+ } catch (e) {
2102
+ console.error(`Error destroying ${fsName}:`, e);
2103
+ }
2104
+ currentLittleFS = null;
2105
+ }
2106
+
2107
+ currentLittleFSPartition = null;
2108
+ currentLittleFSPath = '/';
2109
+ currentFilesystemType = null;
2110
+ littlefsManager.classList.add('hidden');
2111
+ logMsg(`${fsName} manager closed`);
2112
+ }
2113
+
2114
+ /**
2115
+ * Upload file to LittleFS
2116
+ */
2117
+ async function clickLittlefsUpload() {
2118
+ if (!currentLittleFS || !littlefsFileInput.files.length) return;
2119
+
2120
+ const file = littlefsFileInput.files[0];
2121
+
2122
+ try {
2123
+ logMsg(`Uploading file "${file.name}"...`);
2124
+
2125
+ const data = await file.arrayBuffer();
2126
+ const uint8Data = new Uint8Array(data);
2127
+
2128
+ // Construct target path
2129
+ let targetPath = currentLittleFSPath;
2130
+ if (!targetPath.endsWith('/')) targetPath += '/';
2131
+ targetPath += file.name;
2132
+
2133
+ // Ensure parent directories exist
2134
+ const segments = targetPath.split('/').filter(Boolean);
2135
+ if (segments.length > 1) {
2136
+ let built = '';
2137
+ for (let i = 0; i < segments.length - 1; i++) {
2138
+ built += `/${segments[i]}`;
2139
+ try {
2140
+ currentLittleFS.mkdir(built);
2141
+ } catch (e) {
2142
+ // Ignore if directory already exists
2143
+ }
2144
+ }
2145
+ }
2146
+
2147
+ // Write file to LittleFS - EXACTLY like ESPConnect
2148
+ if (typeof currentLittleFS.writeFile === 'function') {
2149
+ currentLittleFS.writeFile(targetPath, uint8Data);
2150
+ } else if (typeof currentLittleFS.addFile === 'function') {
2151
+ currentLittleFS.addFile(targetPath, uint8Data);
2152
+ }
2153
+
2154
+ // Verify by reading back
2155
+ const readBack = currentLittleFS.readFile(targetPath);
2156
+ logMsg(`File written: ${readBack.length} bytes at ${targetPath}`);
2157
+
2158
+ // Clear input
2159
+ littlefsFileInput.value = '';
2160
+ butLittlefsUpload.disabled = true;
2161
+
2162
+ // Refresh list
2163
+ refreshLittleFS();
2164
+
2165
+ logMsg(`File "${file.name}" uploaded successfully`);
2166
+ } catch (e) {
2167
+ errorMsg(`Failed to upload file: ${e.message || e}`);
2168
+ }
2169
+ }
2170
+
2171
+ /**
2172
+ * Create new directory
2173
+ */
2174
+ function clickLittlefsMkdir() {
2175
+ if (!currentLittleFS) return;
2176
+
2177
+ const dirName = prompt('Enter directory name:');
2178
+ if (!dirName || !dirName.trim()) return;
2179
+
2180
+ try {
2181
+ let targetPath = currentLittleFSPath;
2182
+ if (!targetPath.endsWith('/')) targetPath += '/';
2183
+ targetPath += dirName.trim();
2184
+
2185
+ currentLittleFS.mkdir(targetPath);
2186
+ refreshLittleFS();
2187
+
2188
+ logMsg(`Directory "${dirName}" created successfully`);
2189
+ } catch (e) {
2190
+ errorMsg(`Failed to create directory: ${e.message || e}`);
2191
+ }
2192
+ }
2193
+
2194
+ /**
2195
+ * Download file from LittleFS
2196
+ */
2197
+ async function downloadLittleFSFile(path) {
2198
+ if (!currentLittleFS) return;
2199
+
2200
+ try {
2201
+ logMsg(`Downloading file "${path}"...`);
2202
+
2203
+ const data = currentLittleFS.readFile(path);
2204
+ const filename = path.split('/').filter(Boolean).pop() || 'file.bin';
2205
+
2206
+ await saveDataToFile(data, filename);
2207
+
2208
+ logMsg(`File "${filename}" downloaded successfully`);
2209
+ } catch (e) {
2210
+ errorMsg(`Failed to download file: ${e.message || e}`);
2211
+ }
2212
+ }
2213
+
2214
+ /**
2215
+ * Delete file or directory from LittleFS
2216
+ */
2217
+ function deleteLittleFSFile(path, type) {
2218
+ if (!currentLittleFS) return;
2219
+
2220
+ const name = path.split('/').filter(Boolean).pop() || path;
2221
+ const confirmed = confirm(`Delete ${type} "${name}"?`);
2222
+
2223
+ if (!confirmed) return;
2224
+
2225
+ try {
2226
+ if (type === 'dir') {
2227
+ currentLittleFS.delete(path, { recursive: true });
2228
+ } else {
2229
+ currentLittleFS.deleteFile(path);
2230
+ }
2231
+
2232
+ refreshLittleFS();
2233
+ logMsg(`${type === 'dir' ? 'Directory' : 'File'} "${name}" deleted successfully`);
2234
+ } catch (e) {
2235
+ errorMsg(`Failed to delete ${type}: ${e.message || e}`);
2236
+ }
2237
+ }