tasmota-esp-web-tools
Version:
Web tools for ESP devices
2,808 lines • 121 kB
JavaScript
import { __decorate } from "tslib";
import { LitElement, html, css } from "lit";
import { state } from "lit/decorators.js";
import "./components/ew-text-button";
import "./components/ew-checkbox";
import "./components/ew-console";
import "./components/ew-dialog";
import "./components/ew-icon-button";
import "./components/ew-filled-text-field";
import "./components/ew-filled-select";
import "./components/ew-select-option";
import "./components/ew-divider";
import "./components/ew-list";
import "./components/ew-list-item";
import "./components/ew-littlefs-manager";
import "./pages/ew-page-progress";
import "./pages/ew-page-message";
import { closeIcon, warningIcon, checkCircleIcon, listItemInstallIcon, listItemWifi, listItemConsole, listItemVisitDevice, listItemHomeAssistant, listItemEraseUserData, listItemFundDevelopment, firmwareIcon, downloadIcon, } from "./components/svg";
import { ImprovSerial } from "improv-wifi-serial-sdk/dist/serial";
import { ImprovSerialCurrentState, } from "improv-wifi-serial-sdk/dist/const";
import { flash, detectMatchingBuild, getFirmwareFileName } from "./flash";
import { textDownload } from "./util/file-download";
import { fireEvent } from "./util/fire-event";
import { sleep } from "./util/sleep";
import { downloadManifest } from "./util/manifest";
import { dialogStyles } from "./styles";
import { parsePartitionTable } from "./partition.js";
import { detectFilesystemType } from "./util/partition.js";
import { getChipFamilyName } from "./util/chip-family-name";
const ERROR_ICON = warningIcon;
const OK_ICON = checkCircleIcon;
export class EwtInstallDialog extends LitElement {
constructor() {
super(...arguments);
this.logger = console;
this._state = "DASHBOARD";
this._installErase = false;
this._installConfirmed = false;
this._provisionForce = false;
this._wasProvisioned = false;
this._busy = true; // Start as busy until initialization completes
// Name of Ssid. Null = other
this._selectedSsid = null;
// Track if Improv was already checked (to avoid repeated attempts)
this._improvChecked = false;
// Track if console was already opened once (to avoid repeated resets)
this._consoleInitialized = false;
// Track if Improv is supported (separate from active client)
this._improvSupported = false;
// Track if device is using USB-JTAG or USB-OTG (not external serial chip)
this._isUsbJtagOrOtgDevice = false;
// Track action to perform after port reconnection (for USB-JTAG/OTG devices)
this._openConsoleAfterReconnect = false;
this._visitDeviceAfterReconnect = false;
this._addToHAAfterReconnect = false;
this._changeWiFiAfterReconnect = false;
this._handleDisconnect = () => {
this._state = "ERROR";
this._error = "Disconnected";
// Reset flash size and detected build when device is actually disconnected
this._flashSize = undefined;
this._detectedBuild = undefined;
};
}
// Ensure stub is initialized (called before any operation that needs it)
async _ensureStub() {
if (this._espStub && this._espStub.IS_STUB) {
this.logger.log(`Existing stub: IS_STUB=${this._espStub.IS_STUB}, chipFamily=${getChipFamilyName(this._espStub)}`);
// Ensure baudrate is set even if stub already exists
if (this.baudRate && this.baudRate > 115200) {
const currentBaud = this._espStub.currentBaudRate || 115200;
if (currentBaud !== this.baudRate) {
this.logger.log(`Adjusting baudrate from ${currentBaud} to ${this.baudRate}...`);
try {
await this._espStub.setBaudrate(this.baudRate);
this.logger.log(`Baudrate set to ${this.baudRate}`);
// Update currentBaudRate to prevent re-setting
this._espStub.currentBaudRate = this.baudRate;
}
catch (baudErr) {
this.logger.log(`Failed to set baudrate: ${baudErr.message}, continuing with current`);
// Assume baudrate is already correct if setBaudrate fails
this._espStub.currentBaudRate = this.baudRate;
}
}
else {
this.logger.log(`Baudrate already at ${this.baudRate}, skipping`);
}
}
return this._espStub;
}
// Initialize if not already done
if (!this.esploader.chipFamily) {
this.logger.log("Initializing ESP loader...");
// Try twice before giving up
for (let attempt = 1; attempt <= 2; attempt++) {
try {
if (attempt > 1) {
this.logger.log(`Retry attempt ${attempt}/2...`);
await sleep(500); // Wait before retry
}
await this.esploader.initialize();
this.logger.log(`Found ${getChipFamilyName(this.esploader)}`);
break; // Success!
}
catch (err) {
this.logger.error(`Connection failed to stub (attempt ${attempt}/2): ${err.message}`);
if (attempt === 2) {
// Both attempts failed - show error to user
this._state = "ERROR";
this._error = `Failed to connect to ESP after 2 attempts: ${err.message}`;
throw err;
}
}
}
}
// Run stub - chip properties are now automatically inherited from parent
this.logger.log("Running stub...");
const espStub = await this.esploader.runStub();
this.logger.log(`Stub created: IS_STUB=${espStub.IS_STUB}, chipFamily=${getChipFamilyName(espStub)}`);
this._espStub = espStub;
// Detect flash size AFTER stub creation (flash size is only available after stub)
await this._probeFlashSize(espStub);
// Set baudrate BEFORE any operations (use user-selected baudrate if available)
if (this.baudRate && this.baudRate > 115200) {
this.logger.log(`Setting baudrate to ${this.baudRate}...`);
try {
// setBaudrate now supports CDC/JTAG on Android (WebUSB)
await espStub.setBaudrate(this.baudRate);
this.logger.log(`Baudrate set to ${this.baudRate}`);
// Update currentBaudRate to prevent re-setting
espStub.currentBaudRate = this.baudRate;
}
catch (baudErr) {
this.logger.error(`[DEBUG] setBaudrate() threw error: ${baudErr.message}`);
this.logger.log(`Failed to set baudrate: ${baudErr.message}, continuing with default`);
}
}
this.logger.log(`Returning stub: IS_STUB=${this._espStub.IS_STUB}, chipFamily=${getChipFamilyName(this._espStub)}`);
return this._espStub;
}
// Helper to get port from esploader
get _port() {
return this.esploader.port;
}
// Helper to probe flash size from a running stub (only available after stub)
async _probeFlashSize(espStub) {
var _a;
if (espStub.detectFlashSize && !this._flashSize) {
try {
await espStub.detectFlashSize();
this._flashSize = espStub.flashSize;
this.logger.log(`Flash size detected: ${this._flashSize}`);
}
catch (err) {
this.logger.debug("Failed to detect flash size:", err);
}
}
this._detectedBuild = detectMatchingBuild(this._manifest, espStub, this._flashSize, this._isUsbJtagOrOtgDevice, (_a = this._info) === null || _a === void 0 ? void 0 : _a.chipFamily);
}
// Helper to check if device is using USB-JTAG or USB-OTG (not external serial chip)
async _isUsbJtagOrOtg() {
// Use detectUsbConnectionType from tasmota-webserial-esptool
const isUsbJtag = await this.esploader.detectUsbConnectionType();
this.logger.log(`USB-JTAG/OTG detection: ${isUsbJtag ? "YES" : "NO"}`);
return isUsbJtag;
}
// Helper to check if device is WebUSB with external serial chip
async _isWebUsbWithExternalSerial() {
const isWebUsb = this.esploader.isWebUSB && this.esploader.isWebUSB();
if (!isWebUsb) {
return false;
}
const isUsbJtag = await this._isUsbJtagOrOtg();
const result = !isUsbJtag; // WebUSB but NOT USB-JTAG = external serial
this.logger.log(`WebUSB with external serial: ${result ? "YES" : "NO"}`);
return result;
}
// Helper to release reader/writer locks (used by multiple methods)
async _releaseReaderWriter() {
// CRITICAL: Find the actual object that has the reader
// The stub has a _parent pointer, and the reader runs on the parent!
let readerOwner = this._espStub || this.esploader;
if (readerOwner._parent) {
readerOwner = readerOwner._parent;
this.logger.log("Using parent loader for reader/writer");
}
// Cancel the reader on the correct object
if (readerOwner._reader) {
const reader = readerOwner._reader;
try {
await reader.cancel();
this.logger.log("Reader cancelled on correct object");
}
catch (err) {
this.logger.log("Reader cancel failed:", err);
}
try {
reader.releaseLock();
this.logger.log("Reader released");
}
catch (err) {
this.logger.log("Reader releaseLock failed:", err);
}
readerOwner._reader = undefined;
}
// Release the writer on the correct object
if (readerOwner._writer) {
const writer = readerOwner._writer;
readerOwner._writer = undefined;
try {
writer.releaseLock();
this.logger.log("Writer lock released");
}
catch (err) {
this.logger.log("Writer releaseLock failed:", err);
}
}
// For WebUSB (Android), ALWAYS recreate streams
// This is CRITICAL for console to work - WebUSB needs fresh streams
// Even if no locks were held, streams may have been consumed by other operations
if (this.esploader.isWebUSB && this.esploader.isWebUSB()) {
try {
this.logger.log("WebUSB detected - recreating streams");
await this._port.recreateStreams();
await sleep(200);
this.logger.log("WebUSB streams recreated and ready");
}
catch (err) {
this.logger.log(`Failed to recreate WebUSB streams: ${err.message}`);
}
}
}
// Helper to reset baudrate to 115200 for console
// The ESP stub might be at higher baudrate (e.g., 460800) for flashing
// Firmware console always runs at 115200
async _resetBaudrateForConsole() {
if (this._espStub && this._espStub.currentBaudRate !== 115200) {
this.logger.log(`Resetting baudrate from ${this._espStub.currentBaudRate} to 115200`);
try {
// Use setBaudrate from tasmota-webserial-esptool
// This now supports CDC/JTAG baudrate changes on Android (WebUSB)
await this._espStub.setBaudrate(115200);
this.logger.log("Baudrate set to 115200 for console");
}
catch (baudErr) {
this.logger.log(`Failed to set baudrate to 115200: ${baudErr.message}`);
}
}
}
// Helper to prepare ESP for flash operations after Improv check
// Resets to bootloader mode and loads stub
async _prepareForFlashOperations() {
// Reset ESP to BOOTLOADER mode for flash operations
await this._resetToBootloaderAndReleaseLocks();
// Wait for ESP to enter bootloader mode
await sleep(100);
// Reset ESP state (chipFamily preserved from reset if successful)
this._espStub = undefined;
this.esploader.IS_STUB = false;
// Ensure stub is initialized
await this._ensureStub();
this.logger.log("ESP reset, stub loaded - ready for flash operations");
}
// Helper to handle post-flash cleanup and Improv re-initialization
// Called when flash operation completes successfully
async _handleFlashComplete() {
// Check if this is USB-JTAG or USB-OTG device (not external serial chip)
const isUsbJtagOrOtg = await this._isUsbJtagOrOtg();
this._isUsbJtagOrOtgDevice = isUsbJtagOrOtg; // Update state for UI
if (isUsbJtagOrOtg) {
// For USB-JTAG/OTG devices: Reset to firmware mode (port will change!)
// Then user must select new port (User Gesture) and we test Improv
this.logger.log("USB-JTAG/OTG device - resetting to firmware mode");
// CRITICAL: Release locks BEFORE resetToFirmware()
await this._releaseReaderWriter();
// CRITICAL: Forget the old port so browser doesn't show it in selection
try {
await this._port.forget();
this.logger.log("Old port forgotten");
}
catch (forgetErr) {
this.logger.log(`Port forget failed: ${forgetErr.message}`);
}
try {
// Use resetToFirmware() method close the port and device will reboot to firmware
await this.esploader.resetToFirmware();
this.logger.log("Device reset to firmware mode - port closed");
}
catch (err) {
this.logger.debug(`Reset to firmware error (expected): ${err.message}`);
}
// Reset ESP state
await sleep(100);
this._espStub = undefined;
this.esploader.IS_STUB = false;
this.esploader.chipFamily = null;
this._improvChecked = false; // Will check after user reconnects
this._client = null; // Set to null (not undefined) to avoid "Wrapping up" UI state
this._improvSupported = false; // Unknown until after reconnect
this.esploader._reader = undefined;
this.logger.log("Flash complete - waiting for user to select new port");
// CRITICAL: Set state to REQUEST_PORT_SELECTION to show "Select Port" button
this._state = "REQUEST_PORT_SELECTION";
this._error = "";
this.requestUpdate();
return;
}
// Normal flow for non-USB-JTAG/OTG devices
// Release locks and reset ESP state for Improv test
await this._releaseReaderWriter();
// Reset ESP state for Improv test
this._espStub = undefined;
this.esploader.IS_STUB = false;
this.esploader.chipFamily = null;
this._improvChecked = false;
this.esploader._reader = undefined;
this.logger.log("ESP state reset for Improv test");
// Reconnect with 115200 baud and reset ESP to boot into new firmware
try {
// CRITICAL: After flashing at higher baudrate, reconnect at 115200
// reconnectToBootloader() closes port and reopens at 115200 baud
// It now automatically detects WebUSB vs WebSerial and uses appropriate methods
this.logger.log("Reconnecting at 115200 baud for firmware reset...");
try {
await this.esploader.reconnectToBootloader();
this.logger.log("Port reconnected at 115200 baud");
}
catch (reconnectErr) {
this.logger.log(`Reconnect failed: ${reconnectErr.message}`);
}
// Reset device and release locks to ensure clean state for new firmware
// Uses chip-specific reset methods (S2/S3/C3 with USB-JTAG use watchdog)
this.logger.log("Performing hardware reset to start new firmware...");
await this._resetDeviceAndReleaseLocks();
}
catch (resetErr) {
this.logger.log(`Hard reset failed: ${resetErr.message}`);
}
// Test Improv with new firmware
await this._initialize(true);
this.requestUpdate();
}
// Reset device and release locks - used when returning to dashboard or recovering from errors
// Reset device to FIRMWARE mode (normal execution)
async _resetDeviceAndReleaseLocks() {
// Find the actual object that has the reader/writer
let readerOwner = this._espStub || this.esploader;
if (readerOwner._parent) {
readerOwner = readerOwner._parent;
this.logger.log("Using parent loader for reader/writer");
}
// Call hardReset BEFORE releasing locks (so it can communicate)
try {
await this.esploader.hardReset(false);
this.logger.log("Device reset sent");
}
catch (err) {
this.logger.log("Reset error (expected):", err);
}
// Wait for reset to complete
await sleep(500);
// NOW release locks after reset
await this._releaseReaderWriter();
this.logger.log("Device reset to firmware mode");
// Reset ESP state
this._espStub = undefined;
this.esploader.IS_STUB = false;
this.esploader.chipFamily = null;
}
// Reset device to BOOTLOADER mode (for flashing)
// Uses ESPLoader's reconnectToBootloader() to properly close/reopen port
async _resetToBootloaderAndReleaseLocks() {
// Use ESPLoader's reconnectToBootloader() - it handles:
// - Closing port completely (releases all locks)
// - Reopening port at 115200 baud
// - Restarting readLoop()
// - Reset strategies to enter bootloader (connectWithResetStrategies)
// - Chip detection
// - WebUSB vs WebSerial detection and appropriate reset methods
try {
this.logger.log("Resetting ESP to bootloader mode...");
await this.esploader.reconnectToBootloader();
this.logger.log(`ESP in bootloader mode: ${getChipFamilyName(this.esploader)}`);
}
catch (err) {
this.logger.error(`Failed to reset ESP to bootloader: ${err.message}`);
throw err;
}
// Reset stub state (chipFamily is preserved by reconnectToBootloader)
this._espStub = undefined;
this.esploader.IS_STUB = false;
}
render() {
if (!this.esploader) {
return html ``;
}
// Safety check: Don't render DASHBOARD state until Improv check is complete
if (this._state === "DASHBOARD" && !this._improvChecked) {
return html `
<ew-dialog open @cancel=${this._preventDefault}>
<div slot="headline">Connecting</div>
<div slot="content">${this._renderProgress("Initializing")}</div>
</ew-dialog>
`;
}
let heading;
let content;
let allowClosing = false;
// During installation phase we temporarily remove the client
if (this._client === undefined &&
!this._improvChecked && // Only show "Connecting" if we haven't checked yet
this._state !== "INSTALL" &&
this._state !== "LOGS" &&
this._state !== "PARTITIONS" &&
this._state !== "LITTLEFS" &&
this._state !== "REQUEST_PORT_SELECTION" &&
this._state !== "DASHBOARD" // Don't show "Connecting" when in DASHBOARD state
) {
if (this._error) {
[heading, content] = this._renderError(this._error);
}
else {
content = this._renderProgress("Connecting");
}
}
else if (this._state === "INSTALL") {
[heading, content, , allowClosing] = this._renderInstall();
}
else if (this._state === "REQUEST_PORT_SELECTION") {
[heading, content] = this._renderRequestPortSelection();
}
else if (this._state === "ASK_ERASE") {
[heading, content] = this._renderAskErase();
}
else if (this._state === "ERROR") {
[heading, content] = this._renderError(this._error);
}
else if (this._state === "DASHBOARD") {
try {
[heading, content, , allowClosing] =
this._improvSupported && this._info
? this._renderDashboard()
: this._renderDashboardNoImprov();
}
catch (err) {
this.logger.error(`Error rendering dashboard: ${err.message}`, err);
[heading, content] = this._renderError(`Dashboard render error: ${err.message}`);
}
}
else if (this._state === "PROVISION") {
[heading, content] = this._renderProvision();
}
else if (this._state === "LOGS") {
[heading, content] = this._renderLogs();
}
else if (this._state === "PARTITIONS") {
[heading, content] = this._renderPartitions();
}
else if (this._state === "LITTLEFS") {
[heading, content, , allowClosing] = this._renderLittleFS();
}
else {
// Fallback for unknown state
this.logger.error(`Unknown state: ${this._state}`);
[heading, content] = this._renderError(`Unknown state: ${this._state}`);
}
return html `
<ew-dialog
open
@cancel=${this._preventDefault}
@closed=${this._handleClose}
>
${heading
? html `
<div slot="headline">${heading}</div>
${allowClosing
? html `
<ew-icon-button
slot="headline"
@click=${this._closeDialog}
>
${closeIcon}
</ew-icon-button>
`
: ""}
`
: ""}
<div slot="content">${content}</div>
</ew-dialog>
`;
}
_renderProgress(label, progress) {
return html `
<ewt-page-progress
.label=${label}
.progress=${progress}
></ewt-page-progress>
`;
}
_renderError(label) {
const heading = "Error";
const content = html `
<ewt-page-message .icon=${ERROR_ICON} .label=${label}></ewt-page-message>
<ew-text-button slot="actions" @click=${this._closeDialog}
>Close</ew-text-button
>
`;
const hideActions = false;
return [heading, content, hideActions];
}
_renderRequestPortSelection() {
const heading = "Select Port";
const content = html `
<ewt-page-message
.label=${"Device has been reset to firmware mode. The USB port has changed. Please click the button below to select the new port."}
></ewt-page-message>
<ew-text-button
slot="actions"
?disabled=${this._busy}
@click=${this._handleSelectNewPort}
>Select Port</ew-text-button
>
`;
const hideActions = false;
return [heading, content, hideActions];
}
_renderDashboard() {
const heading = this._info.name;
const hideActions = true;
const allowClosing = true;
const content = html `
<ew-list>
<ew-list-item>
<div slot="headline">Connected to ${this._info.name}</div>
<div slot="supporting-text">
${this._info.firmware} ${this._info.version}
(${this._info.chipFamily}${this._flashSize ? `, ${this._flashSize}` : ""})
</div>
</ew-list-item>
${!this._isSameVersion
? html `
<ew-list-item
type="button"
?disabled=${this._busy}
@click=${() => {
if (this._isSameFirmware) {
this._startInstall(false);
}
else if (this._manifest.new_install_prompt_erase) {
this._state = "ASK_ERASE";
}
else {
this._startInstall(true);
}
}}
>
${listItemInstallIcon}
<div slot="headline">
${!this._isSameFirmware
? `Install ${this._manifest.name}`
: `Update ${this._manifest.name}`}
</div>
${(() => {
const label = this._detectedBuild
? this._buildVariantLabel(this._detectedBuild)
: undefined;
return label
? html `<div slot="supporting-text">
Variant: ${label}
</div>`
: "";
})()}
</ew-list-item>
`
: ""}
${!this._client || this._client.nextUrl === undefined
? ""
: html `
<ew-list-item
type="button"
?disabled=${this._busy}
@click=${async () => {
this._busy = true;
// Switch to firmware mode if needed
const needsReconnect = await this._switchToFirmwareMode("visit");
if (needsReconnect) {
return; // Will continue after port reconnection
}
// Device is in firmware mode - open URL
if (this._client && this._client.nextUrl) {
window.open(this._client.nextUrl, "_blank", "noopener,noreferrer");
}
this._busy = false;
}}
>
${listItemVisitDevice}
<div slot="headline">Visit Device</div>
</ew-list-item>
`}
${!this._client ||
!this._manifest.home_assistant_domain ||
this._client.state !== ImprovSerialCurrentState.PROVISIONED
? ""
: html `
<ew-list-item
type="button"
?disabled=${this._busy}
@click=${async () => {
this._busy = true;
// Switch to firmware mode if needed
const needsReconnect = await this._switchToFirmwareMode("homeassistant");
if (needsReconnect) {
return; // Will continue after port reconnection
}
// Device is in firmware mode - open HA URL
if (this._manifest.home_assistant_domain) {
window.open(`https://my.home-assistant.io/redirect/config_flow_start/?domain=${this._manifest.home_assistant_domain}`, "_blank", "noopener,noreferrer");
}
this._busy = false;
}}
>
${listItemHomeAssistant}
<div slot="headline">Add to Home Assistant</div>
</ew-list-item>
`}
${this._client
? html `
<ew-list-item
type="button"
?disabled=${this._busy}
@click=${async () => {
this._busy = true;
// Switch to firmware mode if needed
const needsReconnect = await this._switchToFirmwareMode("wifi");
if (needsReconnect) {
return; // Will continue after port reconnection
}
// Device is in firmware mode
this.logger.log("Device is running firmware for Wi-Fi setup");
// Close Improv client and re-initialize for WiFi setup
if (this._client) {
try {
await this._closeClientWithoutEvents(this._client);
this.logger.log("Improv client closed");
}
catch (e) {
this.logger.log("Failed to close Improv client:", e);
}
this._client = undefined;
// Wait longer for port to be fully released
await sleep(500);
}
// Different handling for different device types:
// - WebSerial: Just release locks
// - WebUSB CDC: Release locks, hardReset, release locks again
// - WebUSB external serial: Just release locks
const isWebUsbExternal = await this._isWebUsbWithExternalSerial();
const isWebUsbCdc = this.esploader.isWebUSB &&
this.esploader.isWebUSB() &&
!isWebUsbExternal;
if (isWebUsbCdc) {
// WebUSB CDC needs hardReset to ensure firmware is running
this.logger.log("WebUSB CDC: Resetting device for Wi-Fi setup...");
try {
// Release locks BEFORE reset
await this._releaseReaderWriter();
// Reset device
await this.esploader.hardReset(false);
this.logger.log("Device reset completed");
// CRITICAL: hardReset consumes streams, recreate them
await this._releaseReaderWriter();
this.logger.log("Streams recreated after reset");
// Wait for device to boot
await sleep(500);
}
catch (err) {
this.logger.log(`Reset error: ${err.message}`);
}
}
else {
// WebSerial or WebUSB external serial: Just release locks
if (isWebUsbExternal) {
this.logger.log("WebUSB external serial: Preparing port for Wi-Fi setup...");
}
else {
this.logger.log("WebSerial: Preparing port for Wi-Fi setup...");
}
await this._releaseReaderWriter();
await sleep(500);
}
this.logger.log("Port ready for new Improv client");
// CRITICAL: Recreate streams one more time to flush any buffered firmware output
// Firmware debug messages can interfere with Improv protocol
this.logger.log("Flushing serial buffer before Improv init...");
await this._releaseReaderWriter();
await sleep(100);
// Re-create Improv client (firmware is running at 115200 baud)
const client = new ImprovSerial(this._port, this.logger);
client.addEventListener("state-changed", () => {
this.requestUpdate();
});
client.addEventListener("error-changed", () => this.requestUpdate());
try {
// Use 10 second timeout to allow device to get IP address
this._info = await client.initialize(10000);
this._client = client;
client.addEventListener("disconnect", this._handleDisconnect);
this.logger.log("Improv client ready for Wi-Fi provisioning");
}
catch (improvErr) {
try {
await this._closeClientWithoutEvents(client);
}
catch (closeErr) {
this.logger.log("Failed to close Improv client after init error:", closeErr);
}
// CRITICAL: Recreate streams after failed Improv init
try {
await this._releaseReaderWriter();
this.logger.log("Streams recreated after Improv failure");
}
catch (releaseErr) {
this.logger.log(`Failed to recreate streams: ${releaseErr.message}`);
}
this.logger.log(`Improv initialization failed: ${improvErr.message}`);
this._error = `Improv initialization failed: ${improvErr.message}`;
this._state = "ERROR";
this._busy = false;
return;
}
this._state = "PROVISION";
this._provisionForce = true;
this._busy = false;
}}
>
${listItemWifi}
<div slot="headline">
${this._client.state === ImprovSerialCurrentState.READY
? "Connect to Wi-Fi"
: "Change Wi-Fi"}
</div>
</ew-list-item>
`
: ""}
${this._isUsbJtagOrOtgDevice
? html `
<ew-list-item
type="button"
?disabled=${this._busy}
@click=${async () => {
this._busy = true;
// Close Improv client if active
if (this._client) {
try {
await this._closeClientWithoutEvents(this._client);
}
catch (e) {
this.logger.log("Failed to close Improv client:", e);
}
}
// Switch to firmware mode if needed
const needsReconnect = await this._switchToFirmwareMode("console");
if (needsReconnect) {
return; // Will continue after port reconnection
}
// Device is already in firmware mode
this.logger.log("Opening console for USB-JTAG/OTG device (in firmware mode)");
this._state = "LOGS";
this._busy = false;
}}
>
${listItemConsole}
<div slot="headline">Open Console</div>
</ew-list-item>
`
: ""}
${!this._isUsbJtagOrOtgDevice
? html `
<ew-list-item
type="button"
?disabled=${this._busy}
@click=${async () => {
const client = this._client;
if (client) {
await this._closeClientWithoutEvents(client);
}
// switch to Firmware mode for Console
await this._switchToFirmwareMode("console");
this._state = "LOGS";
}}
>
${listItemConsole}
<div slot="headline">Logs & Console</div>
</ew-list-item>
`
: ""}
<ew-list-item
type="button"
?disabled=${this._busy}
@click=${async () => {
// Filesystem management requires bootloader mode
// Close Improv client if active (it locks the reader)
if (this._client) {
try {
await this._closeClientWithoutEvents(this._client);
}
catch (e) {
this.logger.log("Failed to close Improv client:", e);
}
}
// Switch to bootloader mode for filesystem operations
this.logger.log("Preparing device for filesystem operations (switching to bootloader mode)...");
try {
await this._prepareForFlashOperations();
await this._ensureStub();
}
catch (err) {
this.logger.log(`Failed to prepare for filesystem: ${err.message}`);
this._state = "ERROR";
this._error = `Failed to enter bootloader mode: ${err.message}`;
return;
}
this._state = "PARTITIONS";
this._readPartitionTable();
}}
>
${firmwareIcon}
<div slot="headline">Manage Filesystem</div>
</ew-list-item>
${this._isSameFirmware && this._manifest.funding_url
? html `
<ew-list-item
type="link"
href=${this._manifest.funding_url}
target="_blank"
>
${listItemFundDevelopment}
<div slot="headline">Fund Development</div>
</ew-list-item>
`
: ""}
${this._isSameVersion
? html `
<ew-list-item
type="button"
class="danger"
?disabled=${this._busy}
@click=${() => this._startInstall(true)}
>
${listItemEraseUserData}
<div slot="headline">Erase User Data</div>
</ew-list-item>
`
: ""}
</ew-list>
`;
return [heading, content, hideActions, allowClosing];
}
_renderDashboardNoImprov() {
const heading = "Device Dashboard";
const hideActions = true;
const allowClosing = true;
// Build device info string if available
const chipFamily = this.esploader.chipFamily
? getChipFamilyName(this.esploader)
: null;
const deviceInfo = chipFamily
? `(${chipFamily}${this._flashSize ? `, ${this._flashSize}` : ""})`
: null;
const content = html `
<ew-list>
${deviceInfo
? html `
<ew-list-item>
<div slot="headline">${chipFamily}</div>
<div slot="supporting-text">${deviceInfo}</div>
</ew-list-item>
`
: ""}
<ew-list-item
type="button"
?disabled=${this._busy}
@click=${() => {
if (this._manifest.new_install_prompt_erase) {
this._state = "ASK_ERASE";
}
else {
// Default is to erase a device that does not support Improv Serial
this._startInstall(true);
}
}}
>
${listItemInstallIcon}
<div slot="headline">Install ${this._manifest.name}</div>
${(() => {
const label = this._detectedBuild
? this._buildVariantLabel(this._detectedBuild)
: undefined;
return label
? html `<div slot="supporting-text">Variant: ${label}</div>`
: "";
})()}
</ew-list-item>
${!this._isUsbJtagOrOtgDevice
? html `
<ew-list-item
type="button"
?disabled=${this._busy}
@click=${async () => {
this._busy = true;
const client = this._client;
if (client) {
await this._closeClientWithoutEvents(client);
}
// switch to Firmware mode for Console
const needsReconnect = await this._switchToFirmwareMode("console");
if (needsReconnect) {
return; // Will continue after port reconnection
}
this._state = "LOGS";
this._busy = false;
}}
>
${listItemConsole}
<div slot="headline">Logs & Console</div>
</ew-list-item>
`
: ""}
${this._isUsbJtagOrOtgDevice
? html `
<ew-list-item
type="button"
?disabled=${this._busy}
@click=${async () => {
this._busy = true;
// Close Improv client if active
if (this._client) {
try {
await this._closeClientWithoutEvents(this._client);
}
catch (e) {
this.logger.log("Failed to close Improv client:", e);
}
}
// Switch to firmware mode if needed
const needsReconnect = await this._switchToFirmwareMode("console");
if (needsReconnect) {
return; // Will continue after port reconnection
}
// Device is already in firmware mode
this.logger.log("Opening console for USB-JTAG/OTG device (in firmware mode)");
this._state = "LOGS";
this._busy = false;
}}
>
${listItemConsole}
<div slot="headline">Open Console</div>
</ew-list-item>
`
: ""}
<ew-list-item
type="button"
?disabled=${this._busy}
@click=${async () => {
// Filesystem management requires bootloader mode
// Close Improv client if active (it locks the reader)
if (this._client) {
try {
await this._closeClientWithoutEvents(this._client);
}
catch (e) {
this.logger.log("Failed to close Improv client:", e);
}
// Keep client object for dashboard rendering; connection already closed above.
}
// Switch to bootloader mode for filesystem operations
this.logger.log("Preparing device for filesystem operations (switching to bootloader mode)...");
try {
await this._prepareForFlashOperations();
await this._ensureStub();
}
catch (err) {
this.logger.log(`Failed to prepare for filesystem: ${err.message}`);
this._state = "ERROR";
this._error = `Failed to enter bootloader mode: ${err.message}`;
return;
}
this._state = "PARTITIONS";
this._readPartitionTable();
}}
>
${firmwareIcon}
<div slot="headline">Manage Filesystem</div>
</ew-list-item>
</ew-list>
`;
return [heading, content, hideActions, allowClosing];
}
_renderProvision() {
let heading = "Configure Wi-Fi";
let content;
let hideActions = false;
if (this._busy) {
return [
heading,
this._renderProgress(this._ssids === undefined
? "Scanning for networks"
: "Trying to connect"),
true,
];
}
if (!this._provisionForce &&
this._client.state === ImprovSerialCurrentState.PROVISIONED) {
heading = undefined;
const showSetupLinks = !this._wasProvisioned &&
(this._client.nextUrl !== undefined ||
"home_assistant_domain" in this._manifest);
hideActions = showSetupLinks;
content = html `
<ewt-page-message
.icon=${OK_ICON}
label="Device connected to the network!"
></ewt-page-message>
${showSetupLinks
? html `
<div class="dashboard-buttons">
${this._client.nextUrl === undefined
? ""
: html `
<div>
<a
href=${this._client.nextUrl}
class="has-button"
target="_blank"
@click=${async (ev) => {
ev.preventDefault();
const url = this._client.nextUrl;
// Preserve user gesture for popup blockers
const popup = window.open("about:blank", "_blank");
// Visit Device opens external page - firmware must running
// Check if device is in bootloader mode
// Switch to firmware mode if needed
const needsReconnect = await this._switchToFirmwareMode("visit");
if (needsReconnect) {
popup === null || popup === void 0 ? void 0 : popup.close();
return; // Will continue after port reconnection
}
// Device is already in firmware mode
this.logger.log("Following Link (in firmware mode)");
if (popup) {
popup.location.href = url;
}
else {
window.open(url, "_blank", "noopener,noreferrer");
}
this._state = "DASHBOARD";
}}
>
<ew-text-button>Visit Device</ew-text-button>
</a>
</div>
`}
${!this._manifest.home_assistant_domain
? ""
: html `
<div>
<a
href=${`https://my.home-assistant.io/redirect/config_flow_start/?domain=${this._manifest.home_assistant_domain}`}
class="has-button"
target="_blank"
@click=${async (ev) => {
ev.preventDefault();
const url = `https://my.home-assistant.io/redirect/config_flow_start/?domain=${this._manifest.home_assistant_domain}`;
const popup = window.open("about:blank", "_blank");
// Add to HA opens external page - firmware must running
// Check if device is in bootloader mode
// Switch to firmware mode if needed
const needsReconnect = await this._switchToFirmwareMode("homeassistant");
if (needsReconnect) {
popup === null || popup === void 0 ? void 0 : popup.close();
return; // Will continue after port reconnection
}
// Device is already in firmware mode
this.logger.log("Following Link (in firmware mode)");
if (popup) {
popup.location.href = url;
}
else {
window.open(url, "_blank", "noopener,noreferrer");
}
this._state = "DASHBOARD";
}}
>
<ew-text-button
>Add to Home Assistant</ew-text-button
>
</a>
</div>
`}
<div>
<ew-text-button
@click=${async () => {
// After WiFi provisioning: Device stays in firmware mode
// Close Improv client first
if (this._client) {
try {
await this._closeClientWithoutEvents(this._client);
this.logger.log("Improv client closed after provisioning");
}
catch (e) {
this.logger.log("Failed to close Improv client:", e);
}
}
// Release locks and stay in firmware mode
await this._releaseReaderWriter();
this.logger.log("Returning to dashboard (device stays in firmware mode)");
this._state = "DASHBOARD";
}}
>Skip</ew-text-button
>
</div>
</div>
`
: html `
<ew-text-button
slot="actions"
@click=${async () => {
// After WiFi provisioning: Device stays in firmware mode
// Close Improv client first
if (this._client) {
try {
await this._closeClientWithoutEvents(this._client);
this.logger.log("Improv client closed after provisioning");
}
catch (e) {
this.logger.log("Failed to close Improv client:", e);
}
}
// Release locks and stay in firmware mode
await this._releaseReaderWriter();
this.logger.log("Returning to dashboard (device stays in firmware mode)");
this._state = "DASHBOARD";
}}
>Continue</ew-text-button
>
`}
`;
}
else {
let error;
switch (this._client.error) {
case 3 /* ImprovSerialErrorState.UNABLE_TO_CONNECT */:
error = "Unable to connect";
break;
// UNKNOWN_RPC_COMMAND happens when list SSIDs not supported.
case 0 /* ImprovSerialErrorState.NO_ERROR */:
case 2 /* ImprovSerialErrorState.UNKNOWN_RPC_COMMAND */:
break;
default:
error = `Unknown error (${this._client.error})`;
}
content = html `
<div>
Enter the credentials of the Wi-Fi network that you want your device
to connect to.
</div>
${error ? html `<p class="error">${error}</p>` : ""}
${this._ssids !== null
? html `
<ew-filled-select
menu-positioning="fixed"
label="Network"
@change=${(ev) => {
const select = ev.target;
const value = select.value;
// The "Join Other" item has value "-1"
this._selectedSsid =
value === "-1"
? null
: this._ssids[parseInt(value)].name;
}}
@closed=${(ev) => ev.stopPropagation()}
>
${this._ssids.map((info, idx) => html `
<ew-select-option
.selected=${this._selectedSsid === info.name}
.value=${String(idx)}
>
${info.name}
</ew-select-option>
`)}
<ew-divider></ew-divider>
<ew-select-option
.selected=${this._selectedSsid === null}
value="-1"
>
Join other…
</ew-select-option>
</ew-filled-select>
`
: ""}
${
// Show input box if command not supported or "Join Other" selected
this._selectedSsid === null
? html `
<ew-filled-text-field
label="Network Name"
name="ssid"
></ew-filled-text-field>
`
: ""}
<ew-filled-text-field
label="Password"
name="password"
type="password"
></ew-filled-text-field>
<ew-text-button
slot="actions"
@click=${async () => {
// When going back from provision: Device stays in firmware mode
// Close Improv client first
if (this._client) {
try {
await this._closeClientWithoutEvents(this._client);
this.logger.log("Improv client closed");
}
catch (e) {
this.logger.log("Failed to close Improv client:", e);
}
}
// Release locks and stay in firmware mode
await this._releaseReaderWriter();
this.logger.log("Returning to dashboard (device stays in firmware mode)");
this._state = "DASHBOARD";
}}
>${this._installState && this._installErase ? "Skip" : "Back"}</ew-text-button
>
<ew-text-button slot="actions" @click=${this._doProvision}
>Connect</ew-text-button
>
`;
}
return [heading, content, hideActions];
}
_renderAskErase() {
const heading = "Erase device";
const content = html `
<div>
Do you want to erase the device before installing
${this._manifest.name}? All data on the device will be lost.
</div>
<label class="formfield danger">
<ew-checkbox touch-target="wrapper"></ew-checkbox>
Erase device
</label>
<ew-text-button
slot="actions"
@click=${() => {
this._state = "DASHBOARD";
}}
>Back</ew-text-button
>
<ew-text-button
slot="actions"
@click=${() => {
const checkbox = this.shadowRoot.querySelector("ew-checkbox");
this._startInstall(checkbox.checked);
}}
>Next</ew-text-button
>
`;
return [heading, content];
}
_renderInstall() {
var _a, _b, _c, _d, _e;
let heading;
let content;
let hideActions = false;
const allowClosing = false;
const isUpdate = !this._installErase && this._isSameFirmware;
if (!this._installConfirmed && this._isSameVersion) {
heading = "Erase User Data";
content = html `
Do you want to reset your device and erase all user data from your
device?
<ew-text-button
slot="actions"
@click=${() => {
this._state = "DASHBOARD";
}}
>Back</ew-text-button
>
<ew-text-button
class="danger"
slot="actions"
@click=${this._confirmInstall}
>Erase User Data</ew-text-button
>
`;
}
else if (!this._installConfirmed) {
heading = "Confirm Installation";
const action = isUpdate ? "update to" : "install";
// Build device info with flash size if available
const deviceInfo = this._flashSize
? html ` (${((_a = this._info) === null || _a === void 0 ? void 0 : _a.chipFamily) || ""}${((_b = this._info) === null || _b === void 0 ? void 0 : _b.chipFamily) ? `, ${this._flashSize}` : this._flashSize})`
: "";
const variantBuild = (_d = (_c = this._installState) === null || _c === void 0 ? void 0 : _c.build) !== null && _d !== void 0 ? _d : this._detectedBuild;
const variantLabel = variantBuild
? this._buildVariantLabel(variantBuild)
: undefined;
content = html `
${isUpdate
? html `Your device is running
${this._info.firmware} ${this._info.version}${deviceInfo}.<br /><br />`
: deviceInfo
? html `Device detected: ${deviceInfo}<br /><br />`
: ""}
Do you want to ${action}
${this._manifest.name} ${this._manifest.version}${variantLabel ? html ` <em>(${variantLabel})</em>` : ""}?
${this._installErase
? html `<br /><br />All data on the device will be erased.`
: ""}
<ew-text-button
slot="actions"
@click=${() => {
this._state = "DASHBOARD";
}}
>Back</ew-text-button
>
<ew-text-button slot="actions" @click=${this._confirmInstall}
>Install</ew-text-button
>
`;
}
else if (!this._installState ||
this._installState.state === "initializing" /* FlashStateType.INITIALIZING */ ||
this._installState.state === "manifest" /* FlashStateType.MANIFEST */ ||
this._installState.state === "preparing" /* FlashStateType.PREPARING */) {
heading = "Installing";
// Show flash size in preparing message if available
const preparingMsg = ((_e = this._installState) === null || _e === void 0 ? void 0 : _e.flashSize)
? `Preparing installation (${this._installState.flashSize})`
: "Preparing installation";
content = this._renderProgress(preparingMsg);
hideActions = true;
}
else if (this._installState.state === "erasing" /* FlashStateType.ERASING */) {
heading = "Installing";
content = this._renderProgress("Erasing");
hideActions = true;
}
else if (this._installState.state === "writing" /* FlashStateType.WRITING */ ||
// When we're finished, keep showing this screen with 100% written
// until Improv is initialized / not detected.
// EXCEPTION: USB-JTAG/OTG devices skip this (they show reconnect message instead)
(this._installState.state === "finished" /* FlashStateType.FINISHED */ &&
this._client === undefined &&
!this._isUsbJtagOrOtgDevice)) {
heading = "Installing";
let percentage;
let undeterminateLabel;
if (this._installState.state === "finished" /* FlashStateType.FINISHED */) {
// We're done writing and detecting improv, show spinner
undeterminateLabel = "Wrapping up";
}
else if (this._installState.details.percentage < 4) {
// We're writing the firmware under 4%, show spinner or else we don't show any pixels
undeterminateLabel = "Installing";
}
else {
// We're writing the firmware over 4%, show progress bar
percentage = this._installState.details.percentage;
}
content = this._renderProgress(html `
${undeterminateLabel ? html `${undeterminateLabel}<br />` : ""}
<br />
This will take a minute.<br />
Keep this page visible until installation is complete.
`, percentage);
hideActions = true;
}
else if (this._installState.state === "finished" /* FlashStateType.FINISHED */ &&
!this._isUsbJtagOrOtgDevice) {
// NOTE: USB-JTAG/OTG devices go directly to REQUEST_PORT_SELECTION
// This is only for external serial chips
heading = undefined;
const supportsImprov = this._client !== null;
content = html `
<ewt-page-message
.icon=${OK_ICON}
label="Installation complete!"
></ewt-page-message>
<ew-text-button
slot="actions"
@click=${() => {
this._state =
supportsImprov && this._installErase ? "PROVISION" : "DASHBOARD";
}}
>Next</ew-text-button
>
`;
}
else if (this._installState.state === "error" /* FlashStateType.ERROR */) {
heading = "Installation failed";
content = html `
<ewt-page-message
.icon=${ERROR_ICON}
.label=${this._installState.message}
></ewt-page-message>
<ew-text-button
slot="actions"
@click=${async () => {
this._improvChecked = false; // Force Improv re-test
await this._initialize(); // Re-test Improv after failed flash
this._state = "DASHBOARD";
}}
>Back</ew-text-button
>
`;
}
return [heading, content, hideActions, allowClosing];
}
_renderLogs() {
const heading = `Logs`;
const hideActions = false;
const content = html `
<ew-console
.port=${this._port}
.logger=${this.logger}
.onReset=${async () => await this.esploader.hardReset(false)}
></ew-console>
<ew-text-button
slot="actions"
@click=${async () => {
await this.shadowRoot.querySelector("ew-console").disconnect();
// After console: ESP stays in firmware mode
// Device will only switch to bootloader mode when "Install" or "Manage Filesystem" is clicked
await this._releaseReaderWriter();
this.logger.log("Returning to dashboard (device stays in firmware mode)");
this._state = "DASHBOARD";
// Don't reset _improvChecked - console only reads, doesn't change firmware
await this._initialize();
}}
>Back</ew-text-button
>
<ew-text-button
slot="actions"
@click=${() => {
textDownload(this.shadowRoot.querySelector("ew-console").logs(), `esp-web-tools-logs.txt`);
this.shadowRoot.querySelector("ew-console").reset();
}}
>${downloadIcon} Download Logs</ew-text-button
>
<ew-text-button
slot="actions"
@click=${async () => {
await this.shadowRoot.querySelector("ew-console").reset();
}}
>Reset Device</ew-text-button
>
`;
return [heading, content, hideActions];
}
_renderPartitions() {
const heading = "Partition Table";
let content;
const hideActions = false;
if (this._busy) {
content = this._renderProgress("Reading partition table...");
}
else if (!this._partitions || this._partitions.length === 0) {
content = html `
<ewt-page-message
.icon=${ERROR_ICON}
label="No partitions found"
></ewt-page-message>
<ew-text-button
slot="actions"
@click=${async () => {
// Just release locks and go back to dashboard
// Device stays in firmware mode (no need to switch)
await this._releaseReaderWriter();
this._state = "DASHBOARD";
// Don't reset _improvChecked - status is still valid after console operations
}}
>Back</ew-text-button
>
`;
}
else {
// Build device info with flash size for partition view
const chipFamily = this.esploader.chipFamily
? getChipFamilyName(this.esploader)
: null;
const deviceInfo = chipFamily
? `${chipFamily}${this._flashSize ? `, ${this._flashSize}` : ""}`
: null;
content = html `
${deviceInfo
? html `<div
class="device-info"
style="margin-bottom: 16px; font-size: 14px; color: var(--md-sys-color-on-surface-variant, #666);"
>
Device: ${deviceInfo}
</div>`
: ""}
<div class="partition-list">
<table class="partition-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>SubType</th>
<th>Offset</th>
<th>Size</th>
<th>Action</th>
</tr>
</thead>
<tbody>
${this._partitions.map((partition) => html `
<tr>
<td>${partition.name}</td>
<td>${partition.typeName}</td>
<td>${partition.subtypeName}</td>
<td>0x${partition.offset.toString(16)}</td>
<td>${this._formatSize(partition.size)}</td>
<td>
${partition.type === 0x01 && partition.subtype === 0x82
? html `
<ew-text-button
@click=${() => this._openFilesystem(partition)}
>Open FS</ew-text-button
>
`
: ""}
</td>
</tr>
`)}
</tbody>
</table>
</div>
<ew-text-button
slot="actions"
@click=${async () => {
try {
// For USB-JTAG/OTG: Need to re-initialize after port changes
// For External Serial: Just go back to dashboard
if (this._isUsbJtagOrOtgDevice) {
this._state = "DASHBOARD";
await this._initialize();
}
else {
// External serial - just go back, device stays in bootloader mode
this._state = "DASHBOARD";
// Ensure _busy is false so buttons are enabled
this._busy = false;
}
}
catch (err) {
this.logger.error(`Partitions Back error: ${err.message}`);
this._state = "ERROR";
this._error = `Failed to return to dashboard: ${err.message}`;
this._busy = false;
}
}}
>Back</ew-text-button
>
`;
}
return [heading, content, hideActions];
}
_renderLittleFS() {
const heading = undefined;
const hideActions = true;
const allowClosing = true;
const content = html `
<ewt-littlefs-manager
.partition=${this._selectedPartition}
.espStub=${this._espStub}
.logger=${this.logger}
.onClose=${() => {
this._state = "PARTITIONS";
}}
></ewt-littlefs-manager>
`;
return [heading, content, hideActions, allowClosing];
}
async _readPartitionTable() {
const PARTITION_TABLE_OFFSET = 0x8000;
const PARTITION_TABLE_SIZE = 0x1000;
this._busy = true;
this._partitions = undefined;
try {
this.logger.log("Reading partition table from 0x8000...");
// Ensure stub is initialized
const espStub = await this._ensureStub();
// Add a small delay after stub is running
await sleep(100);
this.logger.log("Reading flash data...");
const data = await espStub.readFlash(PARTITION_TABLE_OFFSET, PARTITION_TABLE_SIZE);
const partitions = parsePartitionTable(data);
if (partitions.length === 0) {
this.logger.log("No valid partition table found");
this._partitions = [];
}
else {
this.logger.log(`Found ${partitions.length} partition(s)`);
this._partitions = partitions;
}
}
catch (e) {
this.logger.error(`Failed to read partition table: ${e.message || e}`);
if (e.message === "Port selection cancelled") {
await this._releaseReaderWriter();
this._error = "Port selection cancelled";
this._state = "ERROR";
}
else if (e.message && e.message.includes("Failed to connect")) {
// Connection error - show error state so user can retry
await this._releaseReaderWriter();
this._error = e.message;
this._state = "ERROR";
}
else {
// Other errors (like parsing errors) - just show empty partition list
this.logger.log("Returning to partition view with no partitions");
this._partitions = [];
}
}
finally {
// DON'T release reader/writer locks here!
// Keep them so the stub remains usable for:
// - Multiple partition reads
// - Opening filesystem
// The locks will be released when:
// - User clicks "Back" to dashboard (calls _initialize)
// - User clicks "Install Firmware" (flash.ts releases them)
// - Dialog is closed (calls _handleClose)
this._busy = false;
}
}
async _openFilesystem(partition) {
try {
this._busy = true;
this.logger.log(`Detecting filesystem type for partition "${partition.name}"...`);
// Check if ESP stub is still available
if (!this._espStub) {
throw new Error("ESP stub not available. Please reconnect.");
}
const fsType = await detectFilesystemType(this._espStub, partition.offset, partition.size, this.logger);
this.logger.log(`Detected filesystem: ${fsType}`);
if (fsType === "littlefs") {
this._selectedPartition = partition;
this._state = "LITTLEFS";
}
else if (fsType === "spiffs") {
this.logger.error("SPIFFS support not yet implemented. Use LittleFS partitions.");
this._error = "SPIFFS support not yet implemented";
this._state = "ERROR";
}
else {
this.logger.error("Unknown filesystem type. Cannot open partition.");
this._error = "Unknown filesystem type";
this._state = "ERROR";
}
}
catch (e) {
this.logger.error(`Failed to open filesystem: ${e.message || e}`);
this._error = `Failed to open filesystem: ${e.message || e}`;
this._state = "ERROR";
}
finally {
this._busy = false;
}
}
_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`;
}
}
willUpdate(changedProps) {
if (!changedProps.has("_state")) {
return;
}
// Clear errors when changing between pages unless we change
// to the error page.
if (this._state !== "ERROR") {
this._error = undefined;
}
// Scan for SSIDs on provision
if (this._state === "PROVISION") {
this._updateSsids();
}
else {
// Reset this value if we leave provisioning.
this._provisionForce = false;
}
if (this._state === "INSTALL") {
this._installConfirmed = false;
this._installState = undefined;
}
}
async _updateSsids(tries = 0) {
this._ssids = undefined;
this._busy = true;
let ssids;
try {
ssids = await this._client.scan();
}
catch (_err) {
// When we fail while loading, pick "Join other"
if (this._ssids === undefined) {
this._ssids = null;
this._selectedSsid = null;
}
this._busy = false;
return;
}
// We will retry a few times if we don't get any results
if (ssids.length === 0 && tries < 3) {
this.logger.log(`SSID scan returned empty, scheduling retry ${tries + 1}/3`);
setTimeout(() => {
if (this._state === "PROVISION") {
this._updateSsids(tries + 1);
}
}, 2000);
return;
}
this._ssids = ssids;
this._selectedSsid = ssids.length ? ssids[0].name : null;
this._busy = false;
}
firstUpdated(changedProps) {
super.firstUpdated(changedProps);
this._bodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
// Wrap logger to also log to debug component
const originalLogger = this.logger;
this.logger = {
log: (msg, ...args) => {
originalLogger.log(msg, ...args);
},
error: (msg, ...args) => {
originalLogger.error(msg, ...args);
},
debug: (msg, ...args) => {
if (originalLogger.debug) {
originalLogger.debug(msg, ...args);
}
},
};
// CRITICAL: Set logger on esploader so we can see logs from enterConsoleMode() etc.
this.esploader.logger = this.logger;
this._initialize(); // Initial connect - test Improv
}
updated(changedProps) {
super.updated(changedProps);
if (changedProps.has("_state")) {
this.setAttribute("state", this._state);
}
if (this._state !== "PROVISION") {
return;
}
if (changedProps.has("_selectedSsid") && this._selectedSsid === null) {
// If we pick "Join other", select SSID input.
this._focusFormElement("ew-filled-text-field[name=ssid]");
}
else if (changedProps.has("_ssids")) {
// Form is shown when SSIDs are loaded/marked not supported
this._focusFormElement();
}
}
_focusFormElement(selector = "ew-filled-text-field, ew-filled-select") {
const formEl = this.shadowRoot.querySelector(selector);
if (formEl) {
formEl.updateComplete.then(() => setTimeout(() => formEl.focus(), 100));
}
}
async _initialize(justInstalled = false, skipImprov = false) {
if (this._port.readable === null || this._port.writable === null) {
this._state = "ERROR";
this._error =
"Serial port is not readable/writable. Close any other application using it and try again.";
return;
}
// Set busy flag during initialization
this._busy = true;
this.requestUpdate(); // Force UI update to disable buttons immediately
try {
// If local file upload via browser is used, we already provide a manifest as a JSON string and not a URL to it
this._manifest = JSON.parse(this.manifestPath);
}
catch {
// Standard procedure - download manifest.json with provided URL
try {
this._manifest = await downloadManifest(this.manifestPath);
}
catch (_err) {
this._state = "ERROR";
this._error = "Failed to download manifest";
this._busy = false;
return;
}
}
// Skip Improv if requested (e.g., when returning from console or filesystem manager)
if (skipImprov) {
this.logger.log("Skipping Improv test (not needed for this operation)");
this._client = null;
this._improvChecked = true;
this._busy = false;
return;
}
if (this._manifest.new_install_improv_wait_time === 0) {
this._client = null;
this._improvSupported = false;
this._improvChecked = true;
this._busy = false;
return;
}
// Skip Improv if we already checked (avoid repeated attempts)
if (this._improvChecked) {
this.logger.log(`Improv already checked - ${this._improvSupported ? "supported" : "not supported"}, skipping re-test`);
// Ensure _client state is valid for UI rendering
if (!this._improvSupported) {
// Not supported - ensure it's explicitly null for UI
this._client = null;
}
this._busy = false;
this.requestUpdate(); // Force UI update
return;
}
// Skip Improv if we already have a working client
if (this._client) {
this.logger.log("Improv client already active, skipping initialization");
this._improvSupported = true; // If we have a client, Improv is supported
this._improvChecked = true;
this._busy = false;
return;
}
// Check if device is using USB-JTAG or USB-OTG (not external serial chip)
const isUsbJtagOrOtg = await this._isUsbJtagOrOtg();
this._isUsbJtagOrOtgDevice = isUsbJtagOrOtg; // Update state for UI
// Check if device is in bootloader mode
// If yes, switch to firmware mode first (needed for Improv)
const inBootloaderMode = this.esploader.chipFamily !== null;
if (inBootloaderMode) {
this.logger.log("Device is in BOOTLOADER mode - switching to FIRMWARE mode for Improv test");
if (isUsbJtagOrOtg) {
// USB-JTAG/OTG: Need WDT reset → port closes → user must select new port
this.logger.log("USB-JTAG/OTG device - need to switch to firmware mode");
try {
// CRITICAL: Ensure chipFamily is set before calling resetToFirmware()
if (!this.esploader.chipFamily) {
this.logger.log("Detecting chip type...");
await this.esploader.initialize();
this.logger.log(`Chip detected: ${this.esploader.chipFamily}`);
}
// CRITICAL: Create stub before reset
if (!this._espStub) {
this.logger.log("Creating stub for firmware mode switch...");
this._espStub = await this.esploader.runStub();
this.logger.log(`Stub created: IS_STUB=${this._espStub.IS_STUB}`);
}
// Detect flash size after stub is running
await this._probeFlashSize(this._espStub);
// CRITICAL: Save parent loader
const loaderToSave = this._espStub._parent || this._espStub;
this._savedLoaderBeforeConsole = loaderToSave;
// CRITICAL: Release locks BEFORE calling resetToFirmware()
await this._releaseReaderWriter();
// CRITICAL: Forget the old port so browser doesn't show it in selection
try {
await this._port.forget();
this.logger.log("Old port forgotten");
}
catch (forgetErr) {
this.logger.log(`Port forget failed: ${forgetErr.message}`);
}
// Use resetToFirmware() which handles WDT reset and port close
await this.esploader.resetToFirmware();
this.logger.log("Device reset to firmware mode - port closed");
}
catch (err) {
this.logger.debug(`Reset to firmware error (expected): ${err.message}`);
}
// Reset ESP state (port is already closed by resetToFirmware)
await sleep(100);
this._espStub = undefined;
this.esploader.IS_STUB = false;
this.esploader.chipFamily = null;
this._improvChecked = false; // Will check after user reconnects
this._client = undefined;
this._improvSupported = false;
this.esploader._reader = undefined;
this.logger.log("Waiting for user to select new port");
// Show port selection UI
this._state = "REQUEST_PORT_SELECTION";
this._error = "";
this._busy = false;
return;
}
else {
// External serial chip: Can reset to firmware without port change
this.logger.log("External serial chip - resetting to firmware mode");
try {
await this._resetDeviceAndReleaseLocks();
await sleep(500); // Wait for firmware to start
}
catch (err) {
this.logger.log(`Reset to firmware failed: ${err.message}`);
}
}
}
else {
this.logger.log("Device is already in FIRMWARE mode - ready for Improv test");
}
// Ensure streams are ready before Improv test (like Console does)
// This is the ONLY place we call _releaseReaderWriter before Improv test
try {
await this._releaseReaderWriter();
await sleep(200);
this.logger.log("Streams ready for Improv test");
}
catch (err) {
this.logger.log(`Failed to prepare streams: ${err.message}`);
}
// Don't switch to bootloader on initial connect!
// Just test Improv directly - device should now be in firmware mode
this.logger.log("Testing Improv (device is in firmware mode)");
// Calculate timeout for Improv test
// Use longer timeout for initial connect to allow device to get IP address (can take 8+ seconds)
const timeout = !justInstalled
? 10000
: this._manifest.new_install_improv_wait_time !== undefined
? this._manifest.new_install_improv_wait_time * 1000
: 10000;
// Call Improv test with skipReset=true (already reset in _resetDeviceAndReleaseLocks)
await this._testImprov(timeout, true);
}
/**
* Switch device from bootloader mode to firmware mode.
* For USB-JTAG/OTG devices: Requires port reconnection (sets REQUEST_PORT_SELECTION state).
* For external serial: Resets device without port change.
*
* @param actionAfterReconnect - Action to perform after reconnect: 'console', 'visit', 'homeassistant', 'wifi', or null
*/
async _switchToFirmwareMode(actionAfterReconnect = null) {
const inBootloaderMode = this.esploader.chipFamily !== null;
if (!inBootloaderMode) {
this.logger.log("Device already in firmware mode");
// If opening console for the FIRST time, do a reset to ensure device is ready
if (actionAfterReconnect === "console" && !this._consoleInitialized) {
this.logger.log("First console open - resetting device...");
this._consoleInitialized = true;
try {
await this.esploader.hardReset(false);
this.logger.log("Device reset completed");
}
catch (err) {
this.logger.log(`Reset error (expected): ${err.message}`);
}
}
// Even if already in firmware mode, ensure streams are ready
// This is needed for WebUSB after closing Improv client
await this._releaseReaderWriter();
return false; // No switch needed
}
this.logger.log(`Device is in bootloader mode - switching to firmware for ${actionAfterReconnect || "operation"}`);
// CRITICAL: Ensure chipFamily is set
if (!this.esploader.chipFamily) {
this.logger.log("Detecting chip type...");
await this.esploader.initialize();
this.logger.log(`Chip detected: ${this.esploader.chipFamily}`);
}
// CRITICAL: Create stub before reset
if (!this._espStub) {
this.logger.log("Creating stub for firmware mode switch...");
this._espStub = await this.esploader.runStub();
this.logger.log(`Stub created: IS_STUB=${this._espStub.IS_STUB}`);
}
// CRITICAL: Set baudrate to 115200 BEFORE switching
await this._resetBaudrateForConsole();
// CRITICAL: Save parent loader
const loaderToSave = this._espStub._parent || this._espStub;
this._savedLoaderBeforeSwitch = loaderToSave;
// Check if USB-JTAG/OTG device
const isUsbJtagOrOtg = await this._isUsbJtagOrOtg();
if (isUsbJtagOrOtg) {
// USB-JTAG/OTG: Need WDT reset and port reconnection
// CRITICAL: Release locks BEFORE calling resetToFirmware()
this.logger.log("Releasing reader/writer...");
await this._releaseReaderWriter();
try {
// CRITICAL: Forget the old port
try {
await this._port.forget();
this.logger.log("Old port forgotten");
}
catch (forgetErr) {
this.logger.log(`Port forget failed: ${forgetErr.message}`);
}
// Use resetToFirmware() for CDC this is doing a WDT reset
await this.esploader.resetToFirmware();
this.logger.log("Device reset to firmware mode - port closed");
}
catch (err) {
this.logger.debug(`Reset to firmware error (expected): ${err.message}`);
}
// Reset ESP state
await sleep(100);
this._espStub = undefined;
this.esploader.IS_STUB = false;
this.esploader.chipFamily = null;
this._improvChecked = false;
this._client = null;
this._improvSupported = false;
this.esploader._reader = undefined;
// Set flag for action after reconnect
if (actionAfterReconnect === "console") {
this._openConsoleAfterReconnect = true;
}
else if (actionAfterReconnect === "visit") {
this._visitDeviceAfterReconnect = true;
}
else if (actionAfterReconnect === "homeassistant") {
this._addToHAAfterReconnect = true;
}
else if (actionAfterReconnect === "wifi") {
this._changeWiFiAfterReconnect = true;
}
this.logger.log("Waiting for user to select new port");
// Show port selection UI
this._state = "REQUEST_PORT_SELECTION";
this._error = "";
this._busy = false;
return true; // Port reconnection needed
}
else {
// External serial chip: Can reset to firmware without port change
this.logger.log("External serial chip - resetting to firmware mode");
try {
// CRITICAL: Call hardReset BEFORE releasing locks (so it can communicate)
await this.esploader.hardReset(false); // false = firmware mode
this.logger.log("Device reset to firmware mode");
}
catch (err) {
this.logger.log(`Reset worked. Expected slip Timeout read error: ${err.message}`);
}
// Wait for reset to complete
await sleep(500);
// NOW release locks AFTER reset
this.logger.log("Releasing reader/writer after reset...");
await this._releaseReaderWriter();
// Reset ESP state
this._espStub = undefined;
this.esploader.IS_STUB = false;
this.esploader.chipFamily = null;
try {
// Do a hardReset to start firmware
await this.esploader.hardReset(false); // false = firmware mode
this.logger.log("Device in firmware mode, start firmware with reset");
}
catch (err) {
this.logger.log(`Reset error: ${err.message}`);
}
return false; // No port reconnection needed
}
}
_startInstall(erase) {
this._state = "INSTALL";
this._installErase = erase;
this._installConfirmed = false;
}
async _confirmInstall() {
this._installConfirmed = true;
this._installState = undefined;
if (this._client) {
await this._closeClientWithoutEvents(this._client);
}
this._client = undefined;
// For flash operations, we MUST be in bootloader mode
// This is the ONLY place where we switch to bootloader (not on initial connect)
this.logger.log("Preparing device for flash operations (switching to bootloader mode)...");
try {
await this._prepareForFlashOperations();
}
catch (err) {
this.logger.log(`Failed to prepare for flash: ${err.message}`);
this._state = "ERROR";
this._error = `Failed to enter bootloader mode: ${err.message}`;
return;
}
// Ensure stub is initialized before flash
try {
await this._ensureStub();
}
catch (err) {
// Connection failed - show error to user
this._state = "ERROR";
this._error = err.message;
return;
}
// Use the stub for flash
const loaderToUse = this._espStub;
if (this.firmwareFile != undefined) {
// If a uploaded File was provided -> create Uint8Array of content
new Blob([this.firmwareFile])
.arrayBuffer()
.then((b) => this._flashFilebuffer(new Uint8Array(b)));
}
else {
// Use "standard way" with URL to manifest and firmware binary
flash(async (state) => {
this._installState = state;
if (state.state === "finished" /* FlashStateType.FINISHED */) {
// For USB-JTAG/OTG, wait for cleanup before showing port selection
const isUsbJtagOrOtg = await this._isUsbJtagOrOtg();
if (isUsbJtagOrOtg) {
this._isUsbJtagOrOtgDevice = true;
// Wait for reset to complete before showing port selection
await this._handleFlashComplete().catch((err) => {
this.logger.error(`Post-flash cleanup failed: ${(err === null || err === void 0 ? void 0 : err.message) || err}`);
this._state = "ERROR";
this._error = `Post-flash cleanup failed: ${(err === null || err === void 0 ? void 0 : err.message) || err}`;
});
}
else {
// For non-USB-JTAG/OTG, run async (no need to wait)
void this._handleFlashComplete().catch((err) => {
this.logger.error(`Post-flash cleanup failed: ${(err === null || err === void 0 ? void 0 : err.message) || err}`);
this._state = "ERROR";
this._error = `Post-flash cleanup failed: ${(err === null || err === void 0 ? void 0 : err.message) || err}`;
});
}
}
}, loaderToUse, this.logger, this.manifestPath, this._installErase, new Uint8Array(0), this.baudRate).catch((flashErr) => {
this.logger.error(`Flash error: ${flashErr.message || flashErr}`);
this._state = "ERROR";
this._error = `Flash failed: ${flashErr.message || flashErr}`;
this._busy = false;
});
}
}
async _flashFilebuffer(fileBuffer) {
// Stub is already ensured in _confirmInstall
const loaderToUse = this._espStub;
flash((state) => {
this._installState = state;
if (state.state === "finished" /* FlashStateType.FINISHED */) {
void this._handleFlashComplete().catch((err) => {
this.logger.error(`Post-flash cleanup failed: ${(err === null || err === void 0 ? void 0 : err.message) || err}`);
this._state = "ERROR";
this._error = `Post-flash cleanup failed: ${(err === null || err === void 0 ? void 0 : err.message) || err}`;
});
}
}, loaderToUse, this.logger, this.manifestPath, this._installErase, fileBuffer, this.baudRate).catch((flashErr) => {
this.logger.error(`Flash error: ${flashErr.message || flashErr}`);
this._state = "ERROR";
this._error = `Flash failed: ${flashErr.message || flashErr}`;
this._busy = false;
});
}
async _doProvision() {
this._busy = true;
this._wasProvisioned =
this._client.state === ImprovSerialCurrentState.PROVISIONED;
const ssid = this._selectedSsid === null
? this.shadowRoot.querySelector("ew-filled-text-field[name=ssid]").value
: this._selectedSsid;
const password = this.shadowRoot.querySelector("ew-filled-text-field[name=password]").value;
try {
await this._client.provision(ssid, password);
}
catch (_err) {
return;
}
finally {
this._busy = false;
this._provisionForce = false;
}
}
async _handleSelectNewPort() {
// Prevent multiple clicks
if (this._busy) {
this.logger.log("Already processing port selection, ignoring duplicate click");
return;
}
this._busy = true;
this.logger.log("User clicked 'Select Port' button - requesting new port...");
this.logger.log(`Dialog in DOM at start: ${this.parentNode ? "yes" : "no"}`);
// Hide the "Select Port" button immediately and show progress
// This avoids confusion when the port selection dialog appears
this._state = "DASHBOARD"; // Change state to hide the button
this._improvChecked = false; // Show "Connecting" message
this.requestUpdate();
// Ensure dialog stays in DOM
if (!this.parentNode) {
document.body.appendChild(this);
this.logger.log("Dialog re-added to DOM before port selection");
}
let newPort;
try {
// Check if we're using WebUSB (Android) or Web Serial (Desktop)
if (globalThis.requestSerialPort) {
// Android WebUSB
this.logger.log("Using WebUSB port selection (Android)");
newPort = await globalThis.requestSerialPort((msg) => this.logger.log("[WebUSB]", msg));
}
else {
// Desktop Web Serial
this.logger.log("Using Web Serial port selection (Desktop)");
newPort = await navigator.serial.requestPort();
}
// UI updates can happen after requestPort completes
await new Promise((resolve) => setTimeout(resolve, 50));
this.logger.log("Port selected by user");
// Ensure dialog is still in DOM after port selection
if (!this.parentNode) {
document.body.appendChild(this);
this.logger.log("Dialog re-added to DOM after port selection");
}
}
catch (err) {
this.logger.error("Port selection error:", err);
if (err.name === "NotFoundError") {
// User cancelled port selection
this.logger.log("Port selection cancelled by user");
this._busy = false;
this._state = "ERROR";
this._error = "Port selection cancelled";
return;
}
this._busy = false;
this._state = "ERROR";
this._error = `Port selection failed: ${err.message}`;
return;
}
if (!newPort) {
this.logger.error("newPort is null/undefined");
this._busy = false;
this._state = "ERROR";
this._error = "Failed to select port";
return;
}
// Open port at 115200 baud (firmware mode default)
// Port should be closed by resetToFirmware(), but check first
this.logger.log("Opening port at 115200 baud for firmware mode...");
this.logger.log(`Dialog in DOM before opening port: ${this.parentNode ? "yes" : "no"}`);
// Check if port is already open (shouldn't be, but just in case)
if (newPort.readable !== null || newPort.writable !== null) {
this.logger.log("WARNING: Port appears to be open, closing it first...");
try {
await newPort.close();
await sleep(200); // Wait for port to fully close
this.logger.log("Port closed successfully");
}
catch (closeErr) {
this.logger.log(`Port close failed: ${closeErr.message}`);
// Continue anyway - maybe it wasn't really open
}
}
try {
await newPort.open({ baudRate: 115200 });
this.logger.log("Port opened successfully at 115200 baud");
this.logger.log(`Dialog in DOM after opening port: ${this.parentNode ? "yes" : "no"}`);
}
catch (err) {
this.logger.error("Port open error:", err);
this._busy = false;
this._state = "ERROR";
this._error = `Failed to open port: ${err.message}`;
return;
}
// Don't create a new ESPLoader - reuse the existing one and just update the port! -> espStub.port = newPort
this.logger.log("Updating existing ESPLoader with new port for firmware mode...");
// CRITICAL: Update ALL port references!!
// Updates: espStub.port, espStub._parent.port, espLoaderBeforeConsole.port
// 1. Update base loader port (CRITICAL - this is what _port getter uses!)
this.logger.log("Updating base loader port");
this.esploader.port = newPort;
this.esploader.connected = true;
// 2. Update stub port if it exists
if (this._espStub) {
this.logger.log("Updating STUB port");
this._espStub.port = newPort;
this._espStub.connected = true;
// 3. Update parent if it exists
if (this._espStub._parent) {
this.logger.log("Updating parent loader port");
this._espStub._parent.port = newPort;
}
}
// 4. Update saved loader if it exists
if (this._savedLoaderBeforeConsole) {
this.logger.log("Updating saved loader port");
this._savedLoaderBeforeConsole.port = newPort;
}
this.logger.log("ESPLoader port updated for firmware mode (no bootloader sync)");
// Wait for device to fully boot into firmware after WDT reset
// AND for port to be ready for communication
this.logger.log("Waiting 700ms for device to fully boot and port to be ready...");
await sleep(700);
// CRITICAL: Verify port is actually open and ready
this.logger.log(`Port state check: readable=${this._port.readable !== null}, writable=${this._port.writable !== null}`);
// CRITICAL: Check if there are any reader/writer locks that could interfere with Improv
this.logger.log(`Checking for locks: reader=${this.esploader._reader ? "LOCKED" : "free"}, writer=${this.esploader._writer ? "LOCKED" : "free"}`);
if (this.esploader._reader || this.esploader._writer) {
this.logger.log("WARNING: Port has active locks! Releasing them before Improv test...");
await this._releaseReaderWriter();
this.logger.log("Locks released");
}
this.logger.log("Device should be ready now");
// Now test Improv at 115200 baud
this.logger.log("Testing Improv at 115200 baud...");
// Show progress while testing Improv
this._state = "DASHBOARD";
this.requestUpdate(); // Force UI update to show progress
// Continue with Improv test
// For USB-JTAG/OTG: Device is in firmware mode but firmware not started - need reset
// For external serial: Reset ensures device is in clean state
await this._testImprov(1000, false);
}
async _testImprov(timeout = 1000, skipReset = false) {
var _a, _b;
// CRITICAL: Mark Improv as checked BEFORE testing to prevent duplicate tests
this._improvChecked = true;
// CRITICAL: Set _busy = false to ensure menu is enabled even if something fails
// This is set early to prevent menu from staying gray if an unexpected error occurs
// this._busy = false;
// Declare improvSerial outside try block so it's available in catch
let improvSerial;
// Test Improv support
try {
// Use _port getter which returns esploader.port (now updated with new port)
this.logger.log(`Port for Improv: readable=${this._port.readable !== null}, writable=${this._port.writable !== null}`);
const portInfo = this._port.getInfo();
this.logger.log(`Port info: VID=0x${(_a = portInfo.usbVendorId) === null || _a === void 0 ? void 0 : _a.toString(16).padStart(4, "0")}, PID=0x${(_b = portInfo.usbProductId) === null || _b === void 0 ? void 0 : _b.toString(16).padStart(4, "0")}`);
// CRITICAL: Reset device BEFORE testing Improv to ensure firmware is running (unless skipReset is true)
if (!skipReset) {
this.logger.log("Resetting device for Improv detection...");
try {
// Release locks before reset
await this._releaseReaderWriter();
await this.esploader.hardReset(false);
this.logger.log("Device reset sent, device is rebooting...");
// CRITICAL: hardReset consumes the streams
// Need to recreate them before Improv can use the port
await this._releaseReaderWriter();
this.logger.log("Streams recreated after reset");
// Wait for device to boot up
this.logger.log("Waiting for firmware running to be ready for Improv test...");
await sleep(500);
}
catch (resetErr) {
this.logger.log(`Failed to reset device: ${resetErr.message}`);
// Continue anyway
}
}
// CRITICAL: Recreate streams one more time to flush any buffered firmware output
// Firmware debug messages can interfere with Improv protocol
this.logger.log("Flushing serial buffer before Improv init...");
await this._releaseReaderWriter();
await sleep(100);
improvSerial = new ImprovSerial(this._port, this.logger);
improvSerial.addEventListener("state-changed", () => {
this.requestUpdate();
});
improvSerial.addEventListener("error-changed", () => this.requestUpdate());
// Don't set _client until we successfully initialize
this.logger.log("Calling improvSerial.initialize()...");
const info = await improvSerial.initialize(timeout);
// Wait for firmware to complete WiFi scan and connection
// Poll for valid IP address (not 0.0.0.0) by requesting current state with timeout
this.logger.log("Waiting for firmware to get valid IP address (checking every 500ms, max 10 seconds)...");
const startTime = Date.now();
const maxWaitTime = 10000; // 10 seconds max
let hasValidIp = false;
while (Date.now() - startTime < maxWaitTime) {
// Active request current state to get updated URL
try {
await improvSerial.requestCurrentState();
const currentUrl = improvSerial.nextUrl;
if (currentUrl && !currentUrl.includes("0.0.0.0")) {
this.logger.log(`Valid IP found: ${currentUrl}`);
hasValidIp = true;
break;
}
}
catch (err) {
this.logger.log(`Failed to request current state: ${err.message}`);
}
await sleep(500); // Check every 500ms
}
if (!hasValidIp) {
this.logger.log(`Timeout after ${maxWaitTime / 1000} seconds - continuing with current URL: ${improvSerial.nextUrl || "undefined"}`);
}
// Success - set all the values
this._client = improvSerial;
this._info = info;
this._improvSupported = true;
improvSerial.addEventListener("disconnect", this._handleDisconnect);
this.logger.log("Improv Wi-Fi Serial detected");
this.logger.log(`Improv state: ${improvSerial.state}, nextUrl: ${improvSerial.nextUrl || "undefined"}`);
}
catch (err) {
this.logger.log(`Improv Wi-Fi Serial not detected: ${err.message}`);
this._client = null;
this._info = undefined; // Explicitly clear info
this._improvSupported = false;
// _improvChecked is already set to true at the beginning of this method
this.logger.log(`State after Improv failure: _client=${this._client}, _info=${this._info}, _improvSupported=${this._improvSupported}, _improvChecked=${this._improvChecked}`);
// CRITICAL: Close the improvSerial client if it was created
// Even if initialize() failed, the client may have opened streams
if (improvSerial) {
try {
this.logger.log("Closing failed Improv client...");
await improvSerial.close();
this.logger.log("Failed Improv client closed");
// CRITICAL: Wait for streams to be fully released
await sleep(200);
}
catch (closeErr) {
this.logger.log(`Failed to close Improv client: ${closeErr.message}`);
}
}
// CRITICAL: Improv test consumes streams even on failure
// Need to recreate them so console/other features can work
try {
await this._releaseReaderWriter();
this.logger.log("Streams recreated after Improv failure");
}
catch (releaseErr) {
this.logger.log(`Failed to recreate streams: ${releaseErr.message}`);
}
}
// Disable Menu as long improv check is done
this._busy = false;
// Check if user wanted specific action after reconnect
if (this._openConsoleAfterReconnect) {
this.logger.log("Opening console as requested by user");
this._openConsoleAfterReconnect = false; // Reset flag
// CRITICAL: Close Improv client before opening console
if (this._client) {
try {
await this._closeClientWithoutEvents(this._client);
this.logger.log("Improv client closed before opening console");
}
catch (e) {
this.logger.log("Failed to close Improv client:", e);
}
this._client = undefined;
// Wait for port to be ready after closing client
await sleep(200);
}
// Ensure all locks are released
await this._releaseReaderWriter();
this._state = "LOGS";
}
else if (this._visitDeviceAfterReconnect) {
this.logger.log("Opening Visit Device URL as requested by user");
this._visitDeviceAfterReconnect = false; // Reset flag
if (this._client && this._client.nextUrl) {
window.open(this._client.nextUrl, "_blank", "noopener,noreferrer");
}
this._state = "DASHBOARD";
}
else if (this._addToHAAfterReconnect) {
this.logger.log("Opening Home Assistant URL as requested by user");
this._addToHAAfterReconnect = false; // Reset flag
if (this._manifest.home_assistant_domain) {
window.open(`https://my.home-assistant.io/redirect/config_flow_start/?domain=${this._manifest.home_assistant_domain}`, "_blank", "noopener,noreferrer");
}
this._state = "DASHBOARD";
}
else if (this._changeWiFiAfterReconnect) {
this.logger.log("Opening Wi-Fi provisioning as requested by user");
this._changeWiFiAfterReconnect = false; // Reset flag
// Close Improv client and re-initialize for WiFi setup
if (this._client) {
try {
await this._closeClientWithoutEvents(this._client);
}
catch (e) {
this.logger.log("Failed to close Improv client:", e);
}
this._client = undefined;
// Wait for port to be ready after closing client
await sleep(200);
}
// Different handling for different device types:
// - WebSerial: Just release locks
// - WebUSB CDC: Do hardReset + release locks
// - WebUSB external serial: Just release locks
const isWebUsbExternal = await this._isWebUsbWithExternalSerial();
const isWebUsbCdc = this.esploader.isWebUSB &&
this.esploader.isWebUSB() &&
!isWebUsbExternal;
if (isWebUsbCdc) {
// WebUSB CDC needs hardReset
this.logger.log("WebUSB CDC: Resetting device for Wi-Fi setup...");
try {
await this.esploader.hardReset(false);
this.logger.log("Device reset completed");
}
catch (err) {
this.logger.log(`Reset error: ${err.message}`);
}
await this._releaseReaderWriter();
await sleep(200);
}
else {
// WebSerial or WebUSB external serial: Just release locks
if (isWebUsbExternal) {
this.logger.log("WebUSB external serial: Preparing port for Wi-Fi setup...");
}
else {
this.logger.log("WebSerial: Preparing port for Wi-Fi setup...");
}
await this._releaseReaderWriter();
await sleep(200);
}
this.logger.log("Port ready for Wi-Fi setup");
// CRITICAL: Recreate streams one more time to flush any buffered firmware output
// Firmware debug messages can interfere with Improv protocol
this.logger.log("Flushing serial buffer before Improv init...");
await this._releaseReaderWriter();
await sleep(100);
// Re-create Improv client for Wi-Fi provisioning
this.logger.log("Re-initializing Improv Serial for Wi-Fi setup");
const client = new ImprovSerial(this._port, this.logger);
client.addEventListener("state-changed", () => {
this.requestUpdate();
});
client.addEventListener("error-changed", () => this.requestUpdate());
try {
// Use 10 second timeout to allow device to get IP address
this._info = await client.initialize(10000);
this._client = client;
client.addEventListener("disconnect", this._handleDisconnect);
this.logger.log("Improv client ready for Wi-Fi provisioning");
this._state = "PROVISION";
this._provisionForce = true;
}
catch (improvErr) {
try {
await this._closeClientWithoutEvents(client);
}
catch (closeErr) {
this.logger.log("Failed to close Improv client after init error:", closeErr);
}
this.logger.log(`Improv initialization failed: ${improvErr.message}`);
this._error = `Improv initialization failed: ${improvErr.message}`;
this._state = "ERROR";
}
}
else {
this._state = "DASHBOARD";
}
// If dialog was removed from DOM, add it back
if (!this.parentNode) {
document.body.appendChild(this);
this.logger.log("Dialog re-added to DOM");
}
this.requestUpdate(); // Force UI update after state changes
// Additional check to ensure dialog is visible
await new Promise((resolve) => setTimeout(resolve, 100));
}
_preventDefault(ev) {
ev.preventDefault();
}
_closeDialog() {
this._handleClose();
}
async _handleClose() {
var _a;
if (this._client) {
await this._closeClientWithoutEvents(this._client);
}
document.body.style.overflow = (_a = this._bodyOverflow) !== null && _a !== void 0 ? _a : "";
// Reset flash size and detected build when dialog is closed
this._flashSize = undefined;
this._detectedBuild = undefined;
fireEvent(this, "closed");
this.parentNode.removeChild(this);
}
/**
* Return a human-readable label for the differentiating properties of a build.
* Shows the firmware filename from parts (factory preferred, no bootloader/partition).
* Falls back to chipVariant/flashSizeMB/usbInterface if no filename found.
*/
_buildVariantLabel(build) {
const filename = getFirmwareFileName(build);
if (filename)
return filename;
// Fallback: structural qualifiers
const parts = [];
if (build.chipVariant)
parts.push(build.chipVariant);
if (build.flashSizeMB)
parts.push(`${build.flashSizeMB} MB`);
if (build.usbInterface)
parts.push(build.usbInterface);
return parts.length > 0 ? parts.join(", ") : undefined;
}
/**
* Return if the device runs same firmware as manifest.
*/
get _isSameFirmware() {
var _a;
return !this._info
? false
: ((_a = this.overrides) === null || _a === void 0 ? void 0 : _a.checkSameFirmware)
? this.overrides.checkSameFirmware(this._manifest, this._info)
: this._info.firmware === this._manifest.name;
}
/**
* Return if the device runs same firmware and version as manifest.
*/
get _isSameVersion() {
return (this._isSameFirmware && this._info.version === this._manifest.version);
}
async _closeClientWithoutEvents(client) {
// CRITICAL: Always remove event listener BEFORE closing
// This prevents the disconnect event from firing and showing error dialog
client.removeEventListener("disconnect", this._handleDisconnect);
// Then close the client
await client.close();
}
}
EwtInstallDialog.styles = [
dialogStyles,
css `
ew-icon-button {
position: absolute;
right: 4px;
top: 10px;
}
ew-filled-text-field,
ew-filled-select {
display: block;
margin-top: 16px;
}
.dashboard-buttons {
margin: 0 0 -16px -8px;
}
.dashboard-buttons div {
display: block;
margin: 4px 0;
}
ew-list {
margin: 0 -24px;
padding: 0;
--md-list-item-leading-space: 24px;
}
ew-list-item svg {
height: 24px;
}
ew-text-button svg {
width: 16px;
height: 16px;
vertical-align: middle;
margin-right: 4px;
margin-bottom: 2px;
}
a.has-button {
text-decoration: none;
}
.error {
color: var(--danger-color);
}
.danger {
--mdc-theme-primary: var(--danger-color);
--mdc-theme-secondary: var(--danger-color);
--md-sys-color-primary: var(--danger-color);
--md-sys-color-on-surface: var(--danger-color);
}
.formfield {
display: flex;
align-items: center;
gap: 8px;
}
button.link {
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
text-align: left;
text-decoration: underline;
cursor: pointer;
}
ew-dialog {
min-width: min(340px, 100% - 48px);
max-width: min(500px, 100% - 48px);
}
div[slot="headline"] {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:host([state="LOGS"]) ew-dialog {
max-width: 90vw;
max-height: 90vh;
}
ew-console {
width: calc(80vw - 48px);
height: calc(90vh - 168px);
}
:host([state="PARTITIONS"]) ew-dialog {
max-width: min(800px, 100% - 48px);
max-height: 80vh;
}
:host([state="LITTLEFS"]) ew-dialog {
max-width: 95vw;
max-height: 90vh;
}
:host([state="LITTLEFS"]) ewt-littlefs-manager {
display: block;
max-width: 100%;
}
.partition-list {
max-height: 60vh;
overflow-y: auto;
}
.partition-table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
}
.partition-table th,
.partition-table td {
padding: 8px 12px;
text-align: left;
border: 1px solid #ccc;
}
.partition-table th {
font-weight: 600;
background-color: #f0f0f0;
position: sticky;
top: 0;
}
.partition-table tbody tr:hover {
background-color: rgba(3, 169, 244, 0.1);
}
`,
];
__decorate([
state()
], EwtInstallDialog.prototype, "_client", void 0);
__decorate([
state()
], EwtInstallDialog.prototype, "_state", void 0);
__decorate([
state()
], EwtInstallDialog.prototype, "_installErase", void 0);
__decorate([
state()
], EwtInstallDialog.prototype, "_installConfirmed", void 0);
__decorate([
state()
], EwtInstallDialog.prototype, "_installState", void 0);
__decorate([
state()
], EwtInstallDialog.prototype, "_provisionForce", void 0);
__decorate([
state()
], EwtInstallDialog.prototype, "_error", void 0);
__decorate([
state()
], EwtInstallDialog.prototype, "_busy", void 0);
__decorate([
state()
], EwtInstallDialog.prototype, "_ssids", void 0);
__decorate([
state()
], EwtInstallDialog.prototype, "_selectedSsid", void 0);
__decorate([
state()
], EwtInstallDialog.prototype, "_partitions", void 0);
__decorate([
state()
], EwtInstallDialog.prototype, "_selectedPartition", void 0);
__decorate([
state()
], EwtInstallDialog.prototype, "_espStub", void 0);
__decorate([
state()
], EwtInstallDialog.prototype, "_isUsbJtagOrOtgDevice", void 0);
__decorate([
state()
], EwtInstallDialog.prototype, "_flashSize", void 0);
__decorate([
state()
], EwtInstallDialog.prototype, "_detectedBuild", void 0);
customElements.define("ewt-install-dialog", EwtInstallDialog);