ESP32-S3 Web串口调试OTA:打造你的无线调试利器

引言

在嵌入式开发中,调试信息的获取和固件升级往往是两个最频繁的操作。传统方式依赖于串口线,不仅束缚了设备的部署位置,而且在多设备调试时极为不便。本文将带你使用 ESP32-S3PlatformIO 构建一个集 Web 串口调试OTA 固件升级 于一体的可视化控制台,只需浏览器即可实时查看日志、发送命令、上传新固件,彻底摆脱线缆束缚。


资源地址:https://download.csdn.net/download/VOR234/93095605

1. 项目概览

1.1 功能目标

  • Web 串口调试:通过 WebSocket 实时转发 printf/print 输出,在网页上以终端风格显示日志,支持时间戳开关。
  • 双向交互:可在网页输入框发送命令,ESP32 端接收并执行(如控制 LED、查询状态),硬件串口同步打印接收到的命令。
  • OTA 固件升级:通过网页选择 .bin 文件,一键上传并自动烧录到空闲 OTA 分区,设备重启后生效,支持失败回滚。
  • 美观 UI:暗色主题,自适应布局,实时显示设备 IP 和运行时间,连接状态一目了然。

1.2 技术栈

组件选型说明
硬件ESP32-S3 WROOM N8R8 (8MB Flash, 8MB PSRAM)性能强劲,支持大容量 OTA
框架Arduino (PlatformIO)开发便捷,库生态丰富
Web 服务器ESPAsyncWebServer异步非阻塞,性能优异
WebSocket原生 AsyncWebSocket实时双向通信,日志无延迟
OTAUpdate 库 (ESP-IDF 封装)支持分区切换、回滚
前端纯 HTML+CSS+JS无需额外框架,轻量快速

1.3 硬件购买

目前这是我使用的ESP32S3官方硬件👍👍👍(小小的身材有大大的力量)只需要35元加摄像头麦克风79元,后期我会整理相关专栏进行Arduino系统学习😘😘😘。有需要可以购买xiao开发板💕💕💕

  1. SeeedXIAO ESP32S3 Sense硬件购买地址:https://s.click.taobao.com/lekazrt
    在这里插入图片描述

  2. ESP32-S3-CAM 核心开发板 N16R8 wifi蓝牙模块 OV2640摄像头硬件购买地址:https://s.click.taobao.com/1PTagos

在这里插入图片描述

  1. ESP32-S3 NANO开发板 虾哥小智AI 蓝牙WiFi核心板N16R8兼容立创S3

【到手价】27.90元
【下单链接】https://s.click.taobao.com/9uSgqVk
在这里插入图片描述

上面硬件三选一,我是第三款硬件

2. 分区表设计

ESP32-S3 的 Flash 被划分为多个分区,合理的分区表是 OTA 稳定运行的基础。我们为 8MB Flash 设计了如下分区(partition.csv):

# Name,   Type, SubType, Offset,  Size,     Flags
nvs,      data, nvs,     0x9000,  0x5000,
otadata,  data, ota,     0xe000,  0x2000,
app0,     app,  ota_0,   0x10000, 0x380000,
app1,     app,  ota_1,   0x390000,0x380000,
spiffs,   data, spiffs,  0x710000,0x80000,
coredump, data, coredump,0x790000,0x10000,
  • app0 / app1:各 3.5 MB,足够容纳包含 LVGL、TensorFlow Lite 等复杂应用。
  • spiffs:512 KB,用于存储少量网页资源或配置文件(可按需调整为 LittleFS)。
  • otadata:8 KB,记录当前启动的 OTA 分区索引。
  • coredump:64 KB,存储崩溃转储,辅助调试。

将分区表文件放在项目根目录,并在 platformio.ini 中指定:

board_build.partitions = partition.csv

3. 核心实现:WebDebugConsole 类

我们将所有功能封装为 WebDebugConsole 类,对外提供简洁的 API,使得 main.cpp 只需几行代码即可集成。
WebDebugConsole.hpp

#pragma once

#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <Update.h>
#include <functional>

// ========== Web 界面 HTML ==========
static const char WEB_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ESP32-S3 调试控制台</title>
    <style>
        * { box-sizing: border-box; }
        body {
            background: #1e1e2f;
            color: #cdd6f4;
            font-family: 'Fira Code', 'Consolas', monospace;
            margin: 0;
            padding: 20px;
            height: 100vh;
            display: flex;
            flex-direction: column;
        }
        h1 {
            font-size: 1.5rem;
            margin: 0 0 10px 0;
            color: #89b4fa;
            display: flex;
            align-items: center;
            gap: 10px;
            flex-wrap: wrap;
        }
        h1 small { font-size: 0.9rem; color: #a6adc8; font-weight: normal; }
        #log-container {
            flex: 1;
            background: #11111b;
            border: 1px solid #313244;
            border-radius: 12px;
            padding: 15px;
            overflow-y: auto;
            white-space: pre-wrap;
            word-break: break-all;
            font-size: 14px;
            line-height: 1.6;
            margin-bottom: 15px;
            min-height: 200px;
        }
        #log-container::-webkit-scrollbar { width: 8px; }
        #log-container::-webkit-scrollbar-track { background: #1e1e2f; }
        #log-container::-webkit-scrollbar-thumb { background: #45475a; border-radius: 4px; }
        .log-time { color: #6c7086; }
        .input-area {
            display: flex;
            gap: 10px;
            flex-wrap: wrap;
            margin-bottom: 10px;
        }
        .input-area input[type="text"] {
            flex: 1;
            min-width: 150px;
            background: #181825;
            border: 1px solid #45475a;
            border-radius: 8px;
            padding: 10px 14px;
            color: #cdd6f4;
            font-size: 14px;
            outline: none;
        }
        .input-area input[type="text"]:focus { border-color: #89b4fa; }
        .btn {
            background: #313244;
            border: none;
            border-radius: 8px;
            padding: 10px 20px;
            color: #cdd6f4;
            font-size: 14px;
            cursor: pointer;
            transition: 0.2s;
            display: inline-flex;
            align-items: center;
            gap: 6px;
        }
        .btn:hover { background: #45475a; }
        .btn-primary { background: #89b4fa; color: #11111b; }
        .btn-primary:hover { background: #74c7ec; }
        .btn-success { background: #a6e3a1; color: #11111b; }
        .btn-success:hover { background: #94e2d0; }
        .btn-danger { background: #f38ba8; color: #11111b; }
        .btn-danger:hover { background: #eba0ac; }
        .ota-area {
            display: flex;
            align-items: center;
            gap: 12px;
            margin-top: 10px;
            padding: 10px 0;
            border-top: 1px solid #313244;
            flex-wrap: wrap;
        }
        .ota-area input[type="file"] { display: none; }
        .file-label {
            background: #313244;
            border-radius: 8px;
            padding: 10px 16px;
            cursor: pointer;
            transition: 0.2s;
        }
        .file-label:hover { background: #45475a; }
        #status { color: #a6adc8; font-size: 14px; }
        .badge {
            background: #45475a;
            padding: 2px 10px;
            border-radius: 20px;
            font-size: 12px;
        }
        .footer {
            margin-top: 10px;
            color: #6c7086;
            font-size: 12px;
            display: flex;
            justify-content: space-between;
        }
        .options {
            display: flex;
            align-items: center;
            gap: 16px;
            margin-bottom: 8px;
        }
        .options label {
            display: flex;
            align-items: center;
            gap: 4px;
            cursor: pointer;
        }
    </style>
</head>
<body>
    <h1>
        🖥️ Web 调试控制台
        <small id="device-ip">连接中...</small>
        <span class="badge" id="conn-status">⚪ 未连接</span>
        <span class="badge" id="print-status">🟢 打印中</span>
    </h1>
    <div id="log-container"></div>

    <div class="options">
        <label>
            <input type="checkbox" id="showTimestamp" checked> 显示时间戳
        </label>
        <button class="btn btn-danger" id="togglePrintBtn">停止打印</button>
    </div>

    <div class="input-area">
        <input type="text" id="cmdInput" placeholder="输入命令,按 Enter 发送">
        <button class="btn btn-primary" id="sendBtn">发送</button>
        <button class="btn" id="clearBtn">清空日志</button>
    </div>

    <div class="ota-area">
        <span>📦 OTA 升级:</span>
        <label for="filePicker" class="file-label">📂 选择固件 (.bin)</label>
        <input type="file" id="filePicker" accept=".bin">
        <button class="btn btn-success" id="uploadBtn">⬆️ 上传并更新</button>
        <span id="status">就绪</span>
    </div>
    <div class="footer">
        <span>ESP32-S3 · WebDebugConsole</span>
        <span id="uptime">运行时间:0s</span>
    </div>

    <script>
        (function() {
            const logContainer = document.getElementById('log-container');
            const cmdInput = document.getElementById('cmdInput');
            const sendBtn = document.getElementById('sendBtn');
            const clearBtn = document.getElementById('clearBtn');
            const statusSpan = document.getElementById('conn-status');
            const printStatusSpan = document.getElementById('print-status');
            const ipSpan = document.getElementById('device-ip');
            const uptimeSpan = document.getElementById('uptime');
            const filePicker = document.getElementById('filePicker');
            const uploadBtn = document.getElementById('uploadBtn');
            const statusMsg = document.getElementById('status');
            const showTimestamp = document.getElementById('showTimestamp');
            const togglePrintBtn = document.getElementById('togglePrintBtn');

            let ws = null;
            let reconnectTimer = null;
            let printingEnabled = true;

            function connectWebSocket() {
                const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
                const url = `${protocol}//${window.location.host}/webserial`;
                ws = new WebSocket(url);

                ws.onopen = function() {
                    statusSpan.innerHTML = '🟢 已连接';
                    statusSpan.style.color = '#a6e3a1';
                    log('系统', 'WebSocket 连接成功');
                };

                ws.onmessage = function(event) {
                    const msg = event.data;
                    // 检测打印状态广播
                    if (msg.startsWith('PRINT_STATUS:')) {
                        const status = msg.substring(13);
                        if (status === 'ON') {
                            printingEnabled = true;
                            printStatusSpan.innerHTML = '🟢 打印中';
                            printStatusSpan.style.color = '#a6e3a1';
                            togglePrintBtn.textContent = '停止打印';
                            togglePrintBtn.className = 'btn btn-danger';
                        } else if (status === 'OFF') {
                            printingEnabled = false;
                            printStatusSpan.innerHTML = '🔴 已暂停';
                            printStatusSpan.style.color = '#f38ba8';
                            togglePrintBtn.textContent = '恢复打印';
                            togglePrintBtn.className = 'btn btn-success';
                        }
                        return;
                    }
                    log('', msg);
                };

                ws.onclose = function() {
                    statusSpan.innerHTML = '🔴 断开';
                    statusSpan.style.color = '#f38ba8';
                    log('系统', 'WebSocket 断开,尝试重连...');
                    if (reconnectTimer) clearTimeout(reconnectTimer);
                    reconnectTimer = setTimeout(connectWebSocket, 3000);
                };

                ws.onerror = function(err) {
                    // 错误触发时关闭处理会自动进行
                };
            }

            function log(prefix, msg) {
                const line = document.createElement('div');
                if (showTimestamp.checked) {
                    const time = new Date().toLocaleTimeString();
                    line.innerHTML = `<span class="log-time">[${time}]</span> ${prefix ? '['+prefix+'] ' : ''}${msg}`;
                } else {
                    line.textContent = prefix ? '['+prefix+'] ' + msg : msg;
                }
                logContainer.appendChild(line);
                logContainer.scrollTop = logContainer.scrollHeight;
            }

            function sendCommand(cmd) {
                if (ws && ws.readyState === WebSocket.OPEN) {
                    ws.send(cmd);
                    log('你', cmd);
                    cmdInput.value = '';
                } else {
                    log('系统', 'WebSocket 未连接,无法发送');
                }
            }

            // ---- 按钮事件 ----
            sendBtn.addEventListener('click', () => {
                const cmd = cmdInput.value.trim();
                if (cmd) sendCommand(cmd);
            });

            cmdInput.addEventListener('keydown', (e) => {
                if (e.key === 'Enter') {
                    e.preventDefault();
                    sendBtn.click();
                }
            });

            clearBtn.addEventListener('click', () => {
                logContainer.innerHTML = '';
            });

            togglePrintBtn.addEventListener('click', () => {
                if (printingEnabled) {
                    sendCommand('STOP_PRINT');
                } else {
                    sendCommand('RESUME_PRINT');
                }
            });

            // ---- OTA 上传 ----
            uploadBtn.addEventListener('click', () => {
                if (!filePicker.files || filePicker.files.length === 0) {
                    statusMsg.textContent = '⚠️ 请先选择固件文件';
                    return;
                }
                const file = filePicker.files[0];
                if (!file.name.endsWith('.bin')) {
                    statusMsg.textContent = '⚠️ 请选择 .bin 文件';
                    return;
                }
                statusMsg.textContent = '⏳ 上传中...';
                uploadBtn.disabled = true;

                const formData = new FormData();
                formData.append('firmware', file);

                fetch('/update', {
                    method: 'POST',
                    body: formData
                })
                .then(response => response.text())
                .then(data => {
                    statusMsg.textContent = data;
                    uploadBtn.disabled = false;
                    if (data.includes('成功') || data.includes('Success')) {
                        log('OTA', '固件上传成功,设备即将重启...');
                        setTimeout(() => { location.reload(); }, 3000);
                    }
                })
                .catch(err => {
                    statusMsg.textContent = '❌ 上传失败: ' + err;
                    uploadBtn.disabled = false;
                });
            });

            // ---- 获取IP和运行时间 ----
            fetch('/ip')
                .then(res => res.text())
                .then(ip => { ipSpan.textContent = ip; });

            setInterval(() => {
                fetch('/uptime')
                    .then(res => res.text())
                    .then(t => { uptimeSpan.textContent = '运行时间:' + t + 's'; });
            }, 2000);

            connectWebSocket();
        })();
    </script>
</body>
</html>
)rawliteral";

// ========== WebDebugConsole 类 ==========
class WebDebugConsole {
public:
    WebDebugConsole(AsyncWebServer* server) : _server(server), _ws("/webserial") {
        _ws.onEvent([this](AsyncWebSocket* s, AsyncWebSocketClient* c, AwsEventType t, void* arg, uint8_t* data, size_t len) {
            this->onWsEvent(s, c, t, arg, data, len);
        });
    }

    void begin() {
        _server->addHandler(&_ws);

        _server->on("/", HTTP_GET, [](AsyncWebServerRequest* request) {
            request->send_P(200, "text/html", WEB_HTML);
        });

        _server->on("/ip", HTTP_GET, [](AsyncWebServerRequest* request) {
            request->send(200, "text/plain", WiFi.localIP().toString());
        });
        _server->on("/uptime", HTTP_GET, [](AsyncWebServerRequest* request) {
            request->send(200, "text/plain", String(millis() / 1000));
        });

        _server->on("/update", HTTP_POST, [](AsyncWebServerRequest* request) {
            request->send(200, "text/plain", "Upload finished");
        }, [this](AsyncWebServerRequest* request, String filename, size_t index, uint8_t* data, size_t len, bool final) {
            this->handleOTA(request, filename, index, data, len, final);
        });
    }

    void loop() {
        _ws.cleanupClients();
    }

    // ---------- 发送消息(不受暂停控制,始终发送) ----------
    void print(const String& msg) {
        _ws.textAll(msg);
    }
    void println(const String& msg) {
        print(msg + "\n");
    }
    void printf(const char* format, ...) {
        char buffer[256];
        va_list args;
        va_start(args, format);
        vsnprintf(buffer, sizeof(buffer), format, args);
        va_end(args);
        print(String(buffer));
    }

    // ---------- 打印控制 ----------
    void setPrintingEnabled(bool enabled) {
        if (_printingEnabled != enabled) {
            _printingEnabled = enabled;
            // 广播状态给所有客户端
            _ws.textAll(enabled ? "PRINT_STATUS:ON" : "PRINT_STATUS:OFF");
        }
    }
    bool isPrintingEnabled() const {
        return _printingEnabled;
    }

    void onCommand(std::function<void(const String&)> callback) {
        _commandCallback = callback;
    }

private:
    AsyncWebServer* _server;
    AsyncWebSocket _ws;
    std::function<void(const String&)> _commandCallback = nullptr;
    bool _printingEnabled = true;

    void onWsEvent(AsyncWebSocket* server, AsyncWebSocketClient* client, AwsEventType type, void* arg, uint8_t* data, size_t len) {
        switch (type) {
            case WS_EVT_CONNECT:
                Serial.printf("[WebDebug] Client #%u connected\n", client->id());
                // 新连接时发送当前打印状态
                client->text(_printingEnabled ? "PRINT_STATUS:ON" : "PRINT_STATUS:OFF");
                break;
            case WS_EVT_DISCONNECT:
                Serial.printf("[WebDebug] Client #%u disconnected\n", client->id());
                break;
            case WS_EVT_DATA: {
                AwsFrameInfo* info = (AwsFrameInfo*)arg;
                if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) {
                    data[len] = 0;
                    String msg = (char*)data;
                    Serial.print("[WebDebug] Received command: ");
                    Serial.println(msg);

                    // 处理内置打印控制命令
                    if (msg == "STOP_PRINT") {
                        setPrintingEnabled(false);
                        // 不调用回调,直接返回
                        return;
                    } else if (msg == "RESUME_PRINT") {
                        setPrintingEnabled(true);
                        return;
                    }

                    if (_commandCallback) {
                        _commandCallback(msg);
                    }
                }
                break;
            }
            case WS_EVT_ERROR:
                Serial.printf("[WebDebug] WebSocket error\n");
                break;
        }
    }

    void handleOTA(AsyncWebServerRequest* request, String filename, size_t index, uint8_t* data, size_t len, bool final) {
        static bool updateStarted = false;
        if (!index) {
            if (!Update.begin((ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000)) {
                Update.printError(Serial);
                request->send(500, "text/plain", "OTA 初始化失败");
                return;
            }
            updateStarted = true;
            Serial.println("[WebDebug] OTA update started");
        }
        if (updateStarted) {
            if (Update.write(data, len) != len) {
                Update.printError(Serial);
                Update.abort();
                request->send(500, "text/plain", "写入失败");
                updateStarted = false;
                return;
            }
            if (final) {
                if (Update.end(true)) {
                    Serial.println("[WebDebug] OTA update success");
                    request->send(200, "text/plain", "✅ 固件更新成功,设备重启中...");
                    delay(100);
                    ESP.restart();
                } else {
                    Update.printError(Serial);
                    request->send(500, "text/plain", "OTA 结束失败");
                    updateStarted = false;
                }
            }
        }
    }
};

3.1 类结构

class WebDebugConsole {
public:
    WebDebugConsole(AsyncWebServer* server);
    void begin();                    // 挂载路由,启动 WebSocket
    void loop();                     // 清理断开的客户端(需在 loop 调用)
    void print(const String& msg);   // 发送文本(不带时间戳)
    void println(const String& msg);
    void printf(const char* format, ...);
    void onCommand(std::function<void(const String&)> callback);  // 命令回调
private:
    AsyncWebServer* _server;
    AsyncWebSocket _ws;
    std::function<void(const String&)> _commandCallback;
    void onWsEvent(...);
    void handleOTA(...);
};

3.2 WebSocket 通信

WebSocket 路径固定为 /webserial(与前端保持一致)。当收到客户端消息时,会触发 WS_EVT_DATA 事件,解析出文本命令并执行两个动作:

  1. 硬件串口输出Serial.print("[WebDebug] Received command: " + msg);
  2. 用户回调:调用 _commandCallback(msg),让用户自定义命令处理逻辑(如控制 LED)。

发送日志时,调用 _ws.textAll(msg) 广播给所有已连接的网页客户端,实现多端同步查看。

3.3 OTA 固件升级

  • 路由 /update 使用 HTTP_POST 方式接收 multipart/form-data 上传的 .bin 文件。
  • handleOTA 函数中,利用 Update 类进行分区写入:
    • 调用 Update.begin() 指定固件大小(自动选择当前未使用的 OTA 分区)。
    • 分块写入数据,直至全部完成。
    • Update.end(true) 完成后,调用 ESP.restart() 重启,Bootloader 会自动切换到新分区。
  • 上传过程中,网页会显示进度和结果,成功后将自动重载页面。

3.4 自定义 HTML 页面

静态页面 WEB_HTMLPROGMEM 存储在 Flash 中,包含:

  • 日志显示区:滚动显示调试信息,支持 white-space: pre-wrap 保留换行。
  • 时间戳开关:通过复选框控制是否在每条日志前添加 [HH:MM:SS]
  • 命令输入框:回车或点击“发送”即通过 WebSocket 发送。
  • OTA 文件选择与上传:选择 .bin 文件后点击上传,状态提示实时反馈。

前端 JavaScript 负责:

  • 建立 WebSocket 连接,自动重连。
  • 解析并渲染日志。
  • 处理文件上传,使用 FormData 发送 POST 请求。

4. 主程序示例

main.cpp 中只需简单几行即可启用所有功能:

#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include "WebDebugConsole.hpp"

const char *ssid = "LSH_24G";
const char *password = "LSH_2025";

AsyncWebServer server(80);
WebDebugConsole webDebug(&server);

void setup()
{
  Serial.begin(115200);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED)
  {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWiFi connected! IP: " + WiFi.localIP().toString());

  webDebug.begin();

  // 自定义命令回调(不受打印暂停影响)
  webDebug.onCommand([](const String &cmd)
                     {
        if (cmd == "LED_ON") {
            // digitalWrite(LED_BUILTIN, HIGH);
            webDebug.println("LED turned ON");
        } else if (cmd == "LED_OFF") {
            // digitalWrite(LED_BUILTIN, LOW);
            webDebug.println("LED turned OFF");
        } else {
            webDebug.print("Echo: ");
            webDebug.println(cmd);
        } });

  server.begin();
  Serial.println("HTTP server started");
}

void loop()
{
  static unsigned long lastSend = 0;
  // ========== 关键修改:只有打印使能时才发送自动日志 ==========
  if (webDebug.isPrintingEnabled() && millis() - lastSend > 1000)
  {
    // Serial.println("\nWiFi connected! IP: " + WiFi.localIP().toString());
    lastSend = millis();
    webDebug.printf("Free Heap: %u\n", ESP.getFreeHeap());
    webDebug.printf("Uptime: %lu s\n", millis() / 1000);
  }

  webDebug.loop();
}

5. PlatformIO 配置

platformio.ini 关键配置:

[env:freenove_esp32_s3_wroom]
platform = espressif32
board = freenove_esp32_s3_wroom
framework = arduino
board_build.partitions = partition.csv
build_flags = -DARDUINO_USB_CDC_ON_BOOT=1   ; 启用 USB CDC(若使用 USB 串口)
lib_deps = 
    ESPAsyncWebServer-esphome
    AsyncTCP-esphome

注意:我们并未依赖 WebSerialLite,而是完全自建 WebSocket,避免库兼容问题,代码更可控。


6. 使用演示

6.1 烧录与启动

  1. 将项目烧录到 ESP32-S3,打开串口监视器查看分配的 IP 地址。
  2. 确保电脑与 ESP32 在同一局域网。

6.2 访问调试页面

浏览器输入 http://[ESP32-IP]/,即可看到美观的调试控制台:

  • 左上角显示设备 IP,连接状态(绿点表示 WebSocket 已连接)。
  • 日志区会实时显示 webDebug.print 输出的内容。
  • 取消“显示时间戳”可隐藏时间,便于复制日志。
    在这里插入图片描述

6.3 命令交互

在输入框中输入命令(如 LED_ON),点击发送或按回车:

  • 网页会显示 [你] LED_ON
  • ESP32 硬件串口打印 [WebDebug] Received command: LED_ON
  • 回调函数处理命令,并返回执行结果(如 LED turned ON)。

6.4 OTA 升级

  1. 在 PlatformIO 中编译生成 .bin 固件(pio build,文件位于 .pio/build/freenove_esp32_s3_wroom/firmware.bin)。
  2. 在网页点击“选择固件”,选中该文件。
  3. 点击“上传并更新”,进度提示显示“上传中…”,等待几秒后提示成功,设备自动重启。
  4. 刷新页面,运行时间重置,表示已运行新固件。

7. 效果截图

在这里插入图片描述
选择固件"\SerialWeb.pio\build\freenove_esp32_s3_wroom\firmware.bin"
点击更新固件
在这里插入图片描述
上传成功重新启动
在这里插入图片描述


8. 常见问题与注意事项

  • WebSocket 连接失败:检查防火墙是否阻止 80 端口,或 ESP32 与 PC 是否在同一网段。
  • OTA 上传失败:确认分区表大小足够(当前 app0/app1 各 3.5MB,若固件超限需调整分区)。
  • 时间戳显示异常:前端使用 new Date().toLocaleTimeString(),若时区不正确可自行调整。
  • 硬件串口打印:依赖 Serial 对象,若使用 USB CDC,请确保 build_flags 包含 -DARDUINO_USB_CDC_ON_BOOT=1

9. 总结

通过本文,我们成功构建了一个轻量级、可扩展的 Web 调试与 OTA 升级系统。其核心优势在于:

  • 零依赖客户端:只需浏览器,无需安装任何软件。
  • 实时反馈:WebSocket 保证日志无延迟,命令响应即时。
  • 安全可靠:双分区 OTA,升级失败自动回滚。
  • 易于定制:通过回调函数可灵活扩展命令集,适配各种项目需求。

你可以将此框架直接集成到自己的 ESP32-S3 项目中,享受无线调试带来的便利。若你需要进一步优化(如增加身份验证、压缩传输),也可在现有代码基础上轻松实现。


项目源码:可基于文中提供的 WebDebugConsole.hppmain.cpp 快速搭建。

扩展建议

  • 支持多客户端同时查看日志,适合团队协作。
  • 增加日志级别过滤(Info/Warn/Error)。
  • 将 SPIFFS 替换为 LittleFS 提升性能和可靠性。

希望这篇博客能帮助你在嵌入式开发中事半功倍!如有疑问,欢迎留言交流。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐