UNPKG

tasmota-esp-web-tools

Version:
841 lines (804 loc) 27.3 kB
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 @click=${this._refreshFiles} ?disabled=${this._busy} >Refresh</ew-text-button > <ew-text-button @click=${this._backupImage} ?disabled=${this._busy} >Backup Image</ew-text-button > <ew-text-button @click=${this._writeToFlash} ?disabled=${this._busy} >Write to Flash</ew-text-button > <ew-text-button @click=${() => { this._cleanup(); if (this.onClose) this.onClose(); }} ?disabled=${this._busy} >Close</ew-text-button > </div> <div class="littlefs-breadcrumb"> <ew-text-button @click=${this._navigateUp} ?disabled=${this._currentPath === "/" || this._busy} >↑ Up</ew-text-button > <span>${this._currentPath || "/"}</span> </div> <div class="littlefs-file-upload"> <input type="file" @change=${this._handleFileSelect} ?disabled=${this._busy} /> <ew-text-button @click=${this._uploadFile} ?disabled=${!this._selectedFile || this._busy} >Upload File</ew-text-button > <ew-text-button @click=${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" : ""}" @click=${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 @click=${() => this._downloadFile(entry.path)} ?disabled=${this._busy} >Download</ew-text-button > ` : ""} <ew-text-button class="danger" @click=${() => 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; } @keyframes 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 };