tasmota-esp-web-tools
Version:
Web tools for ESP devices
841 lines (804 loc) • 27.3 kB
JavaScript
import { __decorate } from "tslib";
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import "./ew-text-button";
// Dynamic import for LittleFS WASM module
let _wasmBasePath = null;
let _littleFSModule = null;
async function loadLittleFS() {
// Cache the module to avoid reloading
if (_littleFSModule) {
return _littleFSModule;
}
// Determine WASM base path from the current script location
if (!_wasmBasePath) {
const scriptUrl = new URL(import.meta.url);
// Remove the filename to get the directory
const scriptDir = scriptUrl.href.substring(0, scriptUrl.href.lastIndexOf("/") + 1);
_wasmBasePath = scriptDir + "wasm/littlefs/";
}
try {
// Try to import from the calculated path
const indexUrl = _wasmBasePath + "index.js";
// eslint-disable-next-line no-console
console.log("[LittleFS] Loading module from:", indexUrl);
_littleFSModule = await import(/* @vite-ignore */ indexUrl);
return _littleFSModule;
}
catch (err) {
// eslint-disable-next-line no-console
console.error("[LittleFS] Failed to load from calculated path:", _wasmBasePath, err);
// Fallback to relative import (for local development)
try {
_littleFSModule = await import("../wasm/littlefs/index.js");
return _littleFSModule;
}
catch (fallbackErr) {
// eslint-disable-next-line no-console
console.error("[LittleFS] Fallback import also failed:", fallbackErr);
throw new Error(`Failed to load LittleFS module: ${err}`);
}
}
}
let EwtLittleFSManager = class EwtLittleFSManager extends LitElement {
constructor() {
super(...arguments);
this.logger = console;
this._currentPath = "/";
this._files = [];
this._fs = null;
this._blockSize = 4096;
this._usage = { capacityBytes: 0, usedBytes: 0, freeBytes: 0 };
this._diskVersion = "";
this._busy = false;
this._selectedFile = null;
this._flashProgress = 0; // 0-100 for flash progress
this._isFlashing = false;
this._flashOperation = null; // Track operation type
}
async connectedCallback() {
super.connectedCallback();
this.logger.log("LittleFS Manager: connectedCallback called");
await this._openFilesystem();
}
disconnectedCallback() {
super.disconnectedCallback();
this._cleanup();
}
async _openFilesystem() {
try {
this._busy = true;
this._isFlashing = true;
this._flashProgress = 0;
this._flashOperation = "reading";
this.logger.log(`Reading LittleFS partition "${this.partition.name}" (${this._formatSize(this.partition.size)})...`);
if (!this.espStub.IS_STUB) {
throw new Error("ESP stub loader is not running. Cannot read flash.");
}
// Read entire partition with progress callback
const data = await this.espStub.readFlash(this.partition.offset, this.partition.size, (_packet, progress, totalSize) => {
const progressPercent = Math.floor((progress / totalSize) * 100);
this._flashProgress = progressPercent;
});
if (data.length === 0) {
throw new Error("Read 0 bytes from partition");
}
this.logger.log("Mounting LittleFS filesystem...");
// Load LittleFS module dynamically
const { createLittleFSFromImage, formatDiskVersion } = await loadLittleFS();
// Try to mount with different block sizes
const blockSizes = [4096, 2048, 1024, 512];
let fs = null;
let blockSize = 0;
for (const bs of blockSizes) {
try {
const blockCount = Math.floor(this.partition.size / bs);
// Pass WASM URL if available
const options = {
blockSize: bs,
blockCount: blockCount,
};
if (_wasmBasePath) {
options.wasmURL = new URL("littlefs.wasm", _wasmBasePath).href;
}
fs = await createLittleFSFromImage(data, options);
// Try to list root to verify it works
fs.list("/");
blockSize = bs;
this.logger.log(`Successfully mounted LittleFS with block size ${bs}`);
break;
}
catch (_err) {
// Try next block size
fs = null;
}
}
if (!fs) {
throw new Error("Failed to mount LittleFS with any block size");
}
this._fs = fs;
this._blockSize = blockSize;
// Get disk version
try {
const diskVer = fs.getDiskVersion();
if (diskVer && diskVer !== 0) {
this._diskVersion = formatDiskVersion(diskVer);
}
else {
this._diskVersion = "Unknown";
}
}
catch (_e) {
this._diskVersion = "Unknown";
}
this._refreshFiles();
this.logger.log("LittleFS filesystem opened successfully");
}
catch (e) {
this.logger.error(`Failed to open LittleFS: ${e.message || e}`);
if (this.onClose) {
this.onClose();
}
}
finally {
this._busy = false;
this._isFlashing = false;
this._flashProgress = 0;
this._flashOperation = null;
}
}
_refreshFiles() {
if (!this._fs) {
return;
}
try {
// Calculate usage
const allFiles = this._fs.list("/");
const usedBytes = this._estimateUsage(allFiles);
const totalBytes = this.partition.size;
this._usage = {
capacityBytes: totalBytes,
usedBytes: usedBytes,
freeBytes: totalBytes - usedBytes,
};
// List files in current directory
const entries = this._fs.list(this._currentPath);
// Sort: directories first, then files
entries.sort((a, b) => {
if (a.type === "dir" && b.type !== "dir")
return -1;
if (a.type !== "dir" && b.type === "dir")
return 1;
return a.path.localeCompare(b.path);
});
this._files = entries;
}
catch (e) {
this.logger.error(`Failed to refresh file list: ${e.message || e}`);
this._files = [];
}
}
_estimateUsage(entries) {
const block = this._blockSize || 4096;
let total = block * 2; // root metadata copies
for (const entry of entries || []) {
if (entry.type === "dir") {
total += block;
}
else {
const dataBytes = Math.max(1, Math.ceil((entry.size || 0) / block)) * block;
const metadataBytes = block;
total += dataBytes + metadataBytes;
}
}
return total;
}
_formatSize(bytes) {
if (bytes < 1024) {
return `${bytes} B`;
}
else if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(2)} KB`;
}
else {
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
}
_navigateUp() {
if (this._currentPath === "/" || !this._currentPath)
return;
const parts = this._currentPath.split("/").filter(Boolean);
parts.pop();
this._currentPath = "/" + parts.join("/");
if (this._currentPath !== "/" && !this._currentPath.endsWith("/")) {
this._currentPath += "/";
}
this._refreshFiles();
}
_navigateTo(path) {
this._currentPath = path;
this._refreshFiles();
}
async _uploadFile() {
if (!this._fs || !this._selectedFile)
return;
try {
this._busy = true;
this.logger.log(`Uploading file "${this._selectedFile.name}"...`);
const data = await this._selectedFile.arrayBuffer();
const uint8Data = new Uint8Array(data);
// Construct target path
let targetPath = this._currentPath;
if (!targetPath.endsWith("/"))
targetPath += "/";
targetPath += this._selectedFile.name;
// Ensure parent directories exist
const segments = targetPath.split("/").filter(Boolean);
if (segments.length > 1) {
let built = "";
for (let i = 0; i < segments.length - 1; i++) {
built += `/${segments[i]}`;
try {
this._fs.mkdir(built);
}
catch (_e) {
// Ignore if directory already exists
}
}
}
// Write file
if (typeof this._fs.writeFile === "function") {
this._fs.writeFile(targetPath, uint8Data);
}
else if (typeof this._fs.addFile === "function") {
this._fs.addFile(targetPath, uint8Data);
}
// Verify by reading back
const readBack = this._fs.readFile(targetPath);
this.logger.log(`✓ File written: ${readBack.length} bytes at ${targetPath}`);
// Clear input
const uploadedFileName = this._selectedFile.name;
this._selectedFile = null;
this._refreshFiles();
this.logger.log(`File "${uploadedFileName}" uploaded successfully`);
}
catch (e) {
this.logger.error(`Failed to upload file: ${e.message || e}`);
}
finally {
this._busy = false;
}
}
_createFolder() {
if (!this._fs)
return;
const dirName = prompt("Enter directory name:");
if (!dirName || !dirName.trim())
return;
try {
let targetPath = this._currentPath;
if (!targetPath.endsWith("/"))
targetPath += "/";
targetPath += dirName.trim();
this._fs.mkdir(targetPath);
this._refreshFiles();
this.logger.log(`Directory "${dirName}" created successfully`);
}
catch (e) {
this.logger.error(`Failed to create directory: ${e.message || e}`);
}
}
async _downloadFile(path) {
if (!this._fs)
return;
try {
this.logger.log(`Downloading file "${path}"...`);
const data = this._fs.readFile(path);
const filename = path.split("/").filter(Boolean).pop() || "file.bin";
// Create download
const blob = new Blob([data], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.logger.log(`File "${filename}" downloaded successfully`);
}
catch (e) {
this.logger.error(`Failed to download file: ${e.message || e}`);
}
}
_deleteFile(path, type) {
if (!this._fs)
return;
const name = path.split("/").filter(Boolean).pop() || path;
const confirmed = confirm(`Delete ${type} "${name}"?`);
if (!confirmed)
return;
try {
if (type === "dir") {
this._fs.delete(path, { recursive: true });
}
else {
this._fs.deleteFile(path);
}
this._refreshFiles();
this.logger.log(`${type === "dir" ? "Directory" : "File"} "${name}" deleted successfully`);
}
catch (e) {
this.logger.error(`Failed to delete ${type}: ${e.message || e}`);
}
}
async _backupImage() {
if (!this._fs)
return;
try {
this.logger.log("Creating LittleFS backup image...");
const image = this._fs.toImage();
const filename = `${this.partition.name}_littlefs_backup.bin`;
// Create download
const blob = new Blob([image], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.logger.log(`LittleFS backup saved as "${filename}"`);
}
catch (e) {
this.logger.error(`Failed to backup LittleFS: ${e.message || e}`);
}
}
async _writeToFlash() {
if (!this._fs)
return;
const confirmed = confirm(`Write modified LittleFS to flash?\n\n` +
`Partition: ${this.partition.name}\n` +
`Offset: 0x${this.partition.offset.toString(16)}\n` +
`Size: ${this._formatSize(this.partition.size)}\n\n` +
`This will overwrite the current filesystem on the device!`);
if (!confirmed)
return;
try {
this._busy = true;
this._isFlashing = true;
this._flashProgress = 0;
this._flashOperation = "writing"; // Set operation type
this.logger.log("Creating LittleFS image...");
const image = this._fs.toImage();
this.logger.log(`Image created: ${this._formatSize(image.length)}`);
if (image.length > this.partition.size) {
this.logger.error(`Image size (${this._formatSize(image.length)}) exceeds partition size (${this._formatSize(this.partition.size)})`);
return;
}
this.logger.log(`Writing ${this._formatSize(image.length)} to partition "${this.partition.name}" at 0x${this.partition.offset.toString(16)}...`);
// Convert Uint8Array to ArrayBuffer
const imageBuffer = image.buffer.slice(image.byteOffset, image.byteOffset + image.byteLength);
// Write the image to flash with progress callback
await this.espStub.flashData(imageBuffer, (bytesWritten, totalBytes) => {
const percent = Math.floor((bytesWritten / totalBytes) * 100);
this._flashProgress = percent;
}, this.partition.offset);
this.logger.log(`✓ LittleFS successfully written to flash!`);
this.logger.log(`To use the new filesystem, reset your device.`);
}
catch (e) {
this.logger.error(`Failed to write LittleFS to flash: ${e.message || e}`);
}
finally {
this._busy = false;
this._isFlashing = false;
this._flashProgress = 0;
this._flashOperation = null;
}
}
_cleanup() {
if (this._fs) {
// Don't call destroy() - just let garbage collection handle it
this._fs = null;
}
}
_handleFileSelect(e) {
var _a;
const input = e.target;
this._selectedFile = ((_a = input.files) === null || _a === void 0 ? void 0 : _a[0]) || null;
}
render() {
const usedPercent = Math.round((this._usage.usedBytes / this._usage.capacityBytes) * 100);
return html `
<div class="littlefs-manager">
<h3>LittleFS Filesystem Manager</h3>
<div class="littlefs-info">
<div class="littlefs-partition-info">
<strong>Partition:</strong> ${this.partition.name}
<span class="littlefs-size"
>(${this._formatSize(this.partition.size)})</span
>
</div>
<div class="littlefs-usage">
<div class="usage-bar">
<div
class="usage-fill ${this._isFlashing ? "flashing" : ""}"
style="width: ${this._isFlashing
? this._flashProgress
: usedPercent}%"
></div>
</div>
<div class="usage-text">
${this._isFlashing
? html `<span class="flash-status">
⚡
${this._flashOperation === "reading"
? "Reading from"
: "Writing to"}
flash: ${this._flashProgress}%
</span>`
: html `<span
>Used: ${this._formatSize(this._usage.usedBytes)} /
${this._formatSize(this._usage.capacityBytes)}
(${usedPercent}%)</span
>
${this._diskVersion
? html `<span class="disk-version"
>${this._diskVersion}</span
>`
: ""}`}
</div>
</div>
</div>
<div class="littlefs-controls">
<ew-text-button =${this._refreshFiles} ?disabled=${this._busy}
>Refresh</ew-text-button
>
<ew-text-button =${this._backupImage} ?disabled=${this._busy}
>Backup Image</ew-text-button
>
<ew-text-button =${this._writeToFlash} ?disabled=${this._busy}
>Write to Flash</ew-text-button
>
<ew-text-button
=${() => {
this._cleanup();
if (this.onClose)
this.onClose();
}}
?disabled=${this._busy}
>Close</ew-text-button
>
</div>
<div class="littlefs-breadcrumb">
<ew-text-button
=${this._navigateUp}
?disabled=${this._currentPath === "/" || this._busy}
>↑ Up</ew-text-button
>
<span>${this._currentPath || "/"}</span>
</div>
<div class="littlefs-file-upload">
<input
type="file"
=${this._handleFileSelect}
?disabled=${this._busy}
/>
<ew-text-button
=${this._uploadFile}
?disabled=${!this._selectedFile || this._busy}
>Upload File</ew-text-button
>
<ew-text-button =${this._createFolder} ?disabled=${this._busy}
>New Folder</ew-text-button
>
</div>
<div class="littlefs-files">
<table class="file-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${this._files.length === 0
? html `
<tr>
<td colspan="4" class="empty-state">
No files in this directory
</td>
</tr>
`
: this._files.map((entry) => html `
<tr>
<td>
<div
class="file-name ${entry.type === "dir"
? "clickable"
: ""}"
=${entry.type === "dir"
? () => this._navigateTo(entry.path)
: null}
>
<span class="file-icon"
>${entry.type === "dir" ? "📁" : "📄"}</span
>
<span
>${entry.path.split("/").filter(Boolean).pop() ||
"/"}</span
>
</div>
</td>
<td>${entry.type === "dir" ? "Directory" : "File"}</td>
<td>
${entry.type === "file"
? this._formatSize(entry.size)
: "-"}
</td>
<td>
<div class="file-actions">
${entry.type === "file"
? html `
<ew-text-button
=${() => this._downloadFile(entry.path)}
?disabled=${this._busy}
>Download</ew-text-button
>
`
: ""}
<ew-text-button
class="danger"
=${() => this._deleteFile(entry.path, entry.type)}
?disabled=${this._busy}
>Delete</ew-text-button
>
</div>
</td>
</tr>
`)}
</tbody>
</table>
</div>
</div>
`;
}
};
EwtLittleFSManager.styles = css `
:host {
display: block;
}
.littlefs-manager {
width: 100%;
max-width: 100%;
margin: 0 auto;
padding: 15px;
border: 2px solid var(--md-sys-color-primary, #03a9f4);
border-radius: 10px;
background-color: rgba(3, 169, 244, 0.05);
box-sizing: border-box;
}
h3 {
margin: 0 0 15px 0;
color: var(--md-sys-color-primary, #03a9f4);
font-size: 18px;
font-weight: 600;
}
.littlefs-info {
margin-bottom: 15px;
padding: 12px;
background-color: rgba(255, 255, 255, 0.5);
border-radius: 8px;
}
.littlefs-partition-info {
margin-bottom: 10px;
font-size: 13px;
}
.littlefs-size {
color: #666;
margin-left: 8px;
}
.littlefs-usage {
margin-top: 8px;
}
.usage-bar {
width: 100%;
height: 18px;
background-color: #e0e0e0;
border-radius: 10px;
overflow: hidden;
margin-bottom: 6px;
}
.usage-fill {
height: 100%;
background: linear-gradient(
90deg,
var(--md-sys-color-primary, #03a9f4) 0%,
var(--md-sys-color-primary, #03a9f4) 100%
);
transition: width 0.3s ease;
}
.usage-fill.flashing {
background: linear-gradient(90deg, #ff9800 0%, #ff5722 100%);
animation: pulse 1s ease-in-out infinite;
}
pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.flash-status {
font-weight: 600;
color: #ff5722;
}
.usage-text {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #555;
flex-wrap: wrap;
gap: 5px;
}
.disk-version {
font-size: 11px;
padding: 2px 6px;
background-color: var(--md-sys-color-primary, #03a9f4);
color: white;
border-radius: 4px;
}
.littlefs-controls {
display: flex;
gap: 8px;
margin-bottom: 15px;
flex-wrap: wrap;
}
.littlefs-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding: 8px;
background-color: rgba(255, 255, 255, 0.5);
border-radius: 8px;
}
.littlefs-breadcrumb span {
font-family: monospace;
font-size: 13px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.littlefs-file-upload {
display: flex;
gap: 8px;
margin-bottom: 12px;
align-items: center;
flex-wrap: wrap;
}
.littlefs-file-upload input[type="file"] {
flex: 1;
min-width: 150px;
padding: 4px;
border: 2px solid #ccc;
border-radius: 8px;
font-size: 13px;
}
.littlefs-files {
max-height: 350px;
overflow-y: auto;
overflow-x: auto;
border: 1px solid #ccc;
border-radius: 8px;
}
.file-table {
width: 100%;
min-width: 500px;
border-collapse: collapse;
}
.file-table thead {
position: sticky;
top: 0;
background-color: #f5f5f5;
z-index: 10;
}
.file-table th {
padding: 8px;
text-align: left;
font-weight: 600;
border-bottom: 2px solid #ccc;
}
.file-table td {
padding: 8px 10px;
border-bottom: 1px solid #e0e0e0;
}
.file-table tbody tr:hover {
background-color: rgba(3, 169, 244, 0.1);
}
.file-table .empty-state {
text-align: center;
color: #999;
padding: 30px;
font-style: italic;
}
.file-name {
display: flex;
align-items: center;
gap: 8px;
}
.file-name.clickable {
cursor: pointer;
}
.file-name.clickable:hover {
color: var(--md-sys-color-primary, #03a9f4);
text-decoration: underline;
}
.file-icon {
font-size: 16px;
}
.file-actions {
display: flex;
gap: 5px;
}
.danger {
--md-sys-color-primary: var(--danger-color, #db4437);
}
`;
__decorate([
property({ type: Object })
], EwtLittleFSManager.prototype, "partition", void 0);
__decorate([
property({ type: Object })
], EwtLittleFSManager.prototype, "espStub", void 0);
__decorate([
property({ type: Function })
], EwtLittleFSManager.prototype, "logger", void 0);
__decorate([
property({ type: Function })
], EwtLittleFSManager.prototype, "onClose", void 0);
__decorate([
state()
], EwtLittleFSManager.prototype, "_currentPath", void 0);
__decorate([
state()
], EwtLittleFSManager.prototype, "_files", void 0);
__decorate([
state()
], EwtLittleFSManager.prototype, "_fs", void 0);
__decorate([
state()
], EwtLittleFSManager.prototype, "_blockSize", void 0);
__decorate([
state()
], EwtLittleFSManager.prototype, "_usage", void 0);
__decorate([
state()
], EwtLittleFSManager.prototype, "_diskVersion", void 0);
__decorate([
state()
], EwtLittleFSManager.prototype, "_busy", void 0);
__decorate([
state()
], EwtLittleFSManager.prototype, "_selectedFile", void 0);
__decorate([
state()
], EwtLittleFSManager.prototype, "_flashProgress", void 0);
__decorate([
state()
], EwtLittleFSManager.prototype, "_isFlashing", void 0);
__decorate([
state()
], EwtLittleFSManager.prototype, "_flashOperation", void 0);
EwtLittleFSManager = __decorate([
customElement("ewt-littlefs-manager")
], EwtLittleFSManager);
export { EwtLittleFSManager };