tasmota-esp-web-tools
Version:
Web tools for ESP devices
393 lines (392 loc) • 15.1 kB
JavaScript
import { getChipFamilyName } from "./util/chip-family-name";
import { sleep } from "./util/sleep";
import { corsProxyFetch } from "./util/cors-proxy";
/**
* Parse flash size string (e.g., "4MB", "8MB", "16MB") to megabytes number
*/
export function parseFlashSizeToMB(flashSize) {
if (!flashSize)
return undefined;
const match = flashSize.match(/^(\d+)(MB|GB)$/);
if (!match)
return undefined;
const size = parseInt(match[1], 10);
const unit = match[2];
if (unit === "GB")
return size * 1024;
return size;
}
/**
* Select the best build using most-specific-matching algorithm
* - Builds with matching usbInterface are strongly preferred
* - Builds with matching flashSizeMB are preferred
* - Among builds with same specificity, first one wins
* - Builds without these qualifiers are fallback options
*/
function selectBestBuild(builds, detectedFlashSizeMB, detectedUsbInterface) {
if (builds.length === 0)
return undefined;
// Score builds: higher score = more specific match
let bestBuild;
let bestScore = -Infinity;
for (const build of builds) {
let score = 0;
// USB interface match - if specified, must match
if (build.usbInterface !== undefined) {
if (detectedUsbInterface !== undefined &&
build.usbInterface === detectedUsbInterface) {
score += 1000; // Strong preference for explicit usbInterface match
}
else {
// Mismatched usbInterface - disqualify this build
continue;
}
}
// Builds without usbInterface stay neutral (compatible with any)
// Flash size match gives second priority
if (build.flashSizeMB !== undefined && detectedFlashSizeMB !== undefined) {
if (build.flashSizeMB === detectedFlashSizeMB) {
score += 100; // Exact flash size match
}
else {
score -= 1; // Penalize non-matching specific builds
}
}
else if (build.flashSizeMB !== undefined) {
// flashSizeMB is defined but detectedFlashSizeMB is undefined
score -= 1; // Penalize non-matching specific builds
}
// Generic builds (flashSizeMB undefined) stay at score 0
// Prefer this build if it has higher score
// If same score, keep the first one (stable selection)
if (score > bestScore) {
bestScore = score;
bestBuild = build;
}
}
return bestScore >= 0 ? bestBuild : undefined;
}
/**
* Extract the most relevant firmware filename from a build's parts.
* Prefers parts with "factory" in the name.
* Ignores parts with "bootloader" or "partition" in the name.
* Returns just the basename (no path prefix).
*/
export function getFirmwareFileName(build) {
const candidates = build.parts
.map((p) => p.path)
.filter((path) => {
const lower = path.toLowerCase();
return !lower.includes("bootloader") && !lower.includes("partition");
});
if (candidates.length === 0)
return undefined;
const factory = candidates.find((p) => p.toLowerCase().includes("factory"));
const chosen = factory !== null && factory !== void 0 ? factory : candidates[0];
// Return only the basename
return chosen.split("/").pop().split("\\").pop();
}
/**
* Find the best matching build for a given chip configuration.
* This is the single source of truth for build selection used by both
* the flash routine and the install dialog UI.
*/
export function findMatchingBuild(manifest, chipFamily, chipVariant, flashSizeMB, usbInterface) {
const compatible = manifest.builds.filter((b) => {
if (b.chipFamily !== chipFamily)
return false;
if (b.chipVariant && b.chipVariant !== chipVariant)
return false;
return true;
});
const exactVariant = compatible.filter((b) => b.chipVariant !== undefined && b.chipVariant === chipVariant);
const variantAgnostic = compatible.filter((b) => b.chipVariant === undefined);
return (selectBestBuild(exactVariant, flashSizeMB, usbInterface) ||
selectBestBuild(variantAgnostic, flashSizeMB, usbInterface));
}
/**
* Detect the matching build for the currently connected device.
* Extracts chip info directly from the esploader instance.
*
* @param manifest - The loaded firmware manifest
* @param esploader - ESPLoader instance (or stub) with chip info
* @param flashSize - Detected flash size string (e.g. "4MB"), or undefined
* @param isUsbJtagOrOtg - Whether the device uses native USB (CDC) instead of external serial
* @param improvChipFamily - Optional chipFamily from Improv Serial info (takes precedence)
*/
export function detectMatchingBuild(manifest, esploader, flashSize, isUsbJtagOrOtg, improvChipFamily) {
var _a;
const chipFamily = improvChipFamily ||
(esploader.chipFamily ? getChipFamilyName(esploader) : null);
if (!chipFamily)
return undefined;
const chipVariant = (_a = esploader.chipVariant) !== null && _a !== void 0 ? _a : null;
const flashSizeMB = flashSize ? parseFlashSizeToMB(flashSize) : undefined;
const usbInterface = isUsbJtagOrOtg
? "CDC"
: "UART";
return findMatchingBuild(manifest, chipFamily, chipVariant, flashSizeMB, usbInterface);
}
export const flash = async (onEvent, esploader, // ESPLoader instance from tasmota-webserial-esptool
logger, manifestPath, eraseFirst, firmwareBuffer, _baudRate) => {
let manifest;
// eslint-disable-next-line prefer-const
let build;
// eslint-disable-next-line prefer-const
let chipFamily;
let chipVariant = null;
// eslint-disable-next-line prefer-const
let flashSize;
const fireStateEvent = (stateUpdate) => onEvent({
...stateUpdate,
manifest,
build,
chipFamily,
chipVariant,
flashSize,
});
let manifestProm = null;
let manifestURL = "";
try {
manifestProm = JSON.parse(manifestPath);
}
catch {
manifestURL = new URL(manifestPath, location.toString()).toString();
manifestProm = corsProxyFetch(manifestURL).then((resp) => resp.json());
}
// Use the provided ESPLoader instance - NO port logic here!
// For debugging
window.esploader = esploader;
fireStateEvent({
state: "initializing" /* FlashStateType.INITIALIZING */,
message: "Initializing...",
details: { done: false },
});
// Only initialize if not already done
if (!esploader.chipFamily) {
try {
await esploader.initialize();
}
catch (err) {
logger.error(err);
fireStateEvent({
state: "error" /* FlashStateType.ERROR */,
message: "Failed to initialize. Try resetting your device or holding the BOOT button while clicking INSTALL.",
details: { error: "failed_initialize" /* FlashError.FAILED_INITIALIZING */, details: err },
});
if (esploader.connected) {
await esploader.disconnect();
}
return;
}
}
chipFamily = getChipFamilyName(esploader);
chipVariant = esploader.chipVariant;
// Detect flash size if not already detected
if (!esploader.flashSize && esploader.detectFlashSize) {
try {
await esploader.detectFlashSize();
}
catch (err) {
logger.debug("Failed to detect flash size:", err);
}
}
flashSize = esploader.flashSize; // e.g., "4MB", "8MB"
const flashSizeMB = flashSize ? parseFlashSizeToMB(flashSize) : undefined;
// Detect USB connection type to pick CDC vs UART firmware variants
// - true: native USB (USB-JTAG/USB-OTG) -> CDC
// - false: external USB-to-Serial bridge -> UART
let detectedUsbInterface;
if (typeof esploader.detectUsbConnectionType === "function") {
try {
const isUsbJtagOrOtg = await esploader.detectUsbConnectionType();
detectedUsbInterface = isUsbJtagOrOtg ? "CDC" : "UART";
logger.debug(`Detected USB interface: ${detectedUsbInterface}`);
}
catch (err) {
logger.debug("Failed to detect USB connection type:", err);
}
}
fireStateEvent({
state: "initializing" /* FlashStateType.INITIALIZING */,
message: `Initialized. Found ${chipFamily}${chipVariant ? ` (${chipVariant})` : ""}${flashSize ? `, ${flashSize}` : ""}`,
details: { done: true },
});
fireStateEvent({
state: "manifest" /* FlashStateType.MANIFEST */,
message: "Fetching manifest...",
details: { done: false },
});
try {
manifest = await manifestProm;
}
catch (err) {
fireStateEvent({
state: "error" /* FlashStateType.ERROR */,
message: `Unable to fetch manifest: ${err}`,
details: { error: "fetch_manifest_failed" /* FlashError.FAILED_MANIFEST_FETCH */, details: err },
});
await esploader.disconnect();
return;
}
build = findMatchingBuild(manifest, chipFamily, chipVariant, flashSizeMB, detectedUsbInterface);
if (!build) {
fireStateEvent({
state: "error" /* FlashStateType.ERROR */,
message: `Your ${chipFamily}${chipVariant ? ` (${chipVariant})` : ""} is not supported by this firmware.`,
details: { error: "not_supported" /* FlashError.NOT_SUPPORTED */, details: chipFamily },
});
await esploader.disconnect();
return;
}
fireStateEvent({
state: "manifest" /* FlashStateType.MANIFEST */,
message: "Manifest fetched",
details: { done: true },
});
fireStateEvent({
state: "preparing" /* FlashStateType.PREPARING */,
message: "Preparing installation...",
details: { done: false },
});
// The esploader passed in is always a stub (from _ensureStub())
// Baudrate was already set in _ensureStub()
const espStub = esploader;
// Verify stub has chipFamily (should be copied in _ensureStub)
if (!espStub.chipFamily) {
logger.error("Stub missing chipFamily - this should not happen!");
fireStateEvent({
state: "error" /* FlashStateType.ERROR */,
message: "Internal error: Stub not properly initialized",
details: {
error: "failed_initialize" /* FlashError.FAILED_INITIALIZING */,
details: "Missing chipFamily",
},
});
return;
}
// Fetch firmware files
const filePromises = build.parts.map(async (part) => {
const url = new URL(part.path, manifestURL || location.toString()).toString();
const resp = await corsProxyFetch(url);
if (!resp.ok) {
throw new Error(`Downlading firmware ${part.path} failed: ${resp.status}`);
}
return resp.arrayBuffer();
});
// If firmwareBuffer is provided, use it instead of fetching
if (firmwareBuffer) {
filePromises.push(Promise.resolve(firmwareBuffer.buffer));
}
const files = [];
let totalSize = 0;
for (const prom of filePromises) {
try {
const data = await prom;
files.push(data);
totalSize += data.byteLength;
}
catch (err) {
fireStateEvent({
state: "error" /* FlashStateType.ERROR */,
message: err.message,
details: { error: "failed_firmware_download" /* FlashError.FAILED_FIRMWARE_DOWNLOAD */, details: err },
});
await esploader.disconnect();
return;
}
}
fireStateEvent({
state: "preparing" /* FlashStateType.PREPARING */,
message: "Installation prepared",
details: { done: true },
});
// CRITICAL: Erase MUST be done BEFORE writing, if requested
if (eraseFirst) {
fireStateEvent({
state: "erasing" /* FlashStateType.ERASING */,
message: "Erasing flash...",
details: { done: false },
});
try {
logger.log("Erasing flash memory. Please wait...");
await espStub.eraseFlash();
logger.log("Flash erased successfully");
fireStateEvent({
state: "erasing" /* FlashStateType.ERASING */,
message: "Flash erased",
details: { done: true },
});
}
catch (err) {
logger.error(`Flash erase failed: ${err.message}`);
fireStateEvent({
state: "error" /* FlashStateType.ERROR */,
message: `Failed to erase flash: ${err.message}`,
details: { error: "write_failed" /* FlashError.WRITE_FAILED */, details: err },
});
await esploader.disconnect();
return;
}
}
fireStateEvent({
state: "writing" /* FlashStateType.WRITING */,
message: `Writing progress: 0 %`,
details: {
bytesTotal: totalSize,
bytesWritten: 0,
percentage: 0,
},
});
let lastPct = 0;
let totalBytesWritten = 0;
try {
for (let i = 0; i < build.parts.length; i++) {
const part = build.parts[i];
const data = files[i];
await espStub.flashData(data, (bytesWritten, _bytesTotal) => {
const newPct = Math.floor(((totalBytesWritten + bytesWritten) / totalSize) * 100);
if (newPct === lastPct) {
return;
}
lastPct = newPct;
fireStateEvent({
state: "writing" /* FlashStateType.WRITING */,
message: `Writing progress: ${newPct} %`,
details: {
bytesTotal: totalSize,
bytesWritten: totalBytesWritten + bytesWritten,
percentage: newPct,
},
});
}, part.offset);
totalBytesWritten += data.byteLength;
}
}
catch (err) {
fireStateEvent({
state: "error" /* FlashStateType.ERROR */,
message: err.message,
details: { error: "write_failed" /* FlashError.WRITE_FAILED */, details: err },
});
await esploader.disconnect();
return;
}
fireStateEvent({
state: "writing" /* FlashStateType.WRITING */,
message: "Writing complete",
details: {
bytesTotal: totalSize,
bytesWritten: totalSize,
percentage: 100,
},
});
await sleep(100);
// DON'T release locks after flash!
// Keep the stub and locks so the port can be used again
// (e.g., for Improv, Manage Filesystem, or another flash)
fireStateEvent({
state: "finished" /* FlashStateType.FINISHED */,
message: "All done!",
});
};