### HTML + JavaScript OTA流程 使用者開啟瀏覽器 → http://192.168.4.1/ │ ▼ ESP32 回傳 HTML 頁面(透過 AsyncWebServer 提供 index.html) │ ▼ 使用者選擇 Firmware 檔案、按下 [Upload] │ ▼ JavaScript 開始用 FileReader 讀取檔案 │ ▼ JavaScript 建立 WebSocket:「ws://192.168.4.1/ws」 │ ▼ 🟢 ws.onopen → WebSocket 正式連線成功 │ ▼ 前端送出 `{ cmd: "auth", token: "...", size: ... }` │ ▼ ESP32 收到 → 驗證 token → Update.begin(size) │ ▼ 前端開始送 binary 資料(chunk by chunk) │ ▼ ESP32: Update.write(...) 並計算 running_crc │ ▼ 傳送完後,前端再送出 `{ cmd: "crc", crc32: "0x..." }` │ ▼ ESP32: 若 CRC 符合 → Update.end(true) → ESP.restart() ### ESP32S3 程式 ``` #include <WiFi.h> #include <AsyncTCP.h> #include <ESPAsyncWebServer.h> #include <Update.h> #include <LittleFS.h> #include <ArduinoJson.h> const char *ssid = "iot"; const char *password = "chosemaker"; AsyncWebServer server(80); AsyncWebSocket ws("/ws"); class OTAState { public: bool authorized; bool in_progress; uint32_t expected_crc; size_t total_size; size_t received_size; OTAState(bool auth = false, bool in_prog = false, uint32_t crc = 0, size_t total = 0, size_t received = 0) : authorized(auth), in_progress(in_prog), expected_crc(crc), total_size(total), received_size(received) { } }; OTAState ota; uint32_t running_crc = 0; const char *validToken = "123456"; uint32_t crc32_update(uint32_t crc, const uint8_t *data, size_t len) { crc = ~crc; while (len--) { crc ^= *data++; for (int k = 0; k < 8; k++) crc = crc & 1 ? (crc >> 1) ^ 0xEDB88320 : crc >> 1; } return ~crc; } void handleWsMessage(AsyncWebSocketClient *client, void *arg, uint8_t *data, size_t len) { AwsFrameInfo *info = (AwsFrameInfo *)arg; if (info->final && info->index == 0 && info->len == len) { if (info->opcode == WS_TEXT) { JsonDocument doc; DeserializationError err = deserializeJson(doc, data, len); if (err) { Serial.printf("[WS] Client %u json_parse_failed\n", client->id()); client->text("{\"error\":\"json_parse_failed\"}"); return; } String cmd = doc["cmd"] | ""; if (cmd == "auth") { Serial.printf("[WS] Client %u cmd == auth\n", client->id()); if (doc["token"] != validToken) { Serial.printf("[WS] Client %u invalid_token\n", client->id()); client->text("{\"error\":\"invalid_token\"}"); return; } size_t size = doc["size"]; Serial.printf("[WS] Client %u data size = %d\n", client->id(), size); if (!Update.begin(size)) { Serial.printf("[WS] Client %u update_begin_failed\n", client->id()); client->text("{\"error\":\"update_begin_failed\"}"); return; } ota = OTAState(true, true, 0, size, 0); running_crc = 0; Serial.printf("[WS] Client %u auth_ok, running_crc = %d\n", client->id(), running_crc); client->text("{\"status\":\"auth_ok\"}"); } else if (cmd == "crc") { Serial.printf("[WS] Client %u cmd == crc\n", client->id()); if (!ota.in_progress || !ota.authorized) return; ota.expected_crc = strtoul(doc["crc32"], nullptr, 16); Serial.printf("[WS] Client %u, ota.received_size = %d, ota.expected_crc = 0x%x\n", client->id(), ota.received_size, ota.expected_crc); if (running_crc == ota.expected_crc) { if (Update.end(true)) { client->text("{\"status\":\"update_success\"}"); Serial.printf("[WS] Client %u update_success\n", client->id()); delay(1000); ESP.restart(); } else { Serial.printf("[WS] Client %u update_end_failed\n", client->id()); client->text("{\"error\":\"update_end_failed\"}"); } } else { Serial.printf("[WS] Client %u crc_mismatch, running_crc = 0x%x, expected_crc = 0x%x\n", client->id(), running_crc, ota.expected_crc); client->text("{\"error\":\"crc_mismatch\"}"); Update.abort(); } ota = OTAState(); // Reset } } else if (info->opcode == WS_BINARY) { if (!ota.in_progress || !ota.authorized) return; if (Update.write(data, len) != len) { Serial.printf("[WS] Client %u write_failed\n", client->id()); client->text("{\"error\":\"write_failed\"}"); Update.abort(); ota = OTAState(); return; } running_crc = crc32_update(running_crc, data, len); ota.received_size += len; client->text("{\"status\":\"ack\"}"); // 回傳確認 } } } void setup() { Serial.begin(921600); WiFi.softAP(ssid, password); Serial.println("IP: " + WiFi.softAPIP().toString()); if (!LittleFS.begin()) { Serial.println("LittleFS mount failed"); return; } ws.onEvent([](AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len) { switch (type) { case WS_EVT_CONNECT: Serial.printf("[WS] Client %u connected\n", client->id()); break; case WS_EVT_DISCONNECT: Serial.printf("[WS] Client %u disconnected\n", client->id()); break; case WS_EVT_DATA: handleWsMessage(client, arg, data, len); break; default: break; } }); server.addHandler(&ws); // 提供 index.html server.serveStatic("/", LittleFS, "/").setDefaultFile("web_ui.html"); server.begin(); pinMode(15, OUTPUT); pinMode(16, OUTPUT); } void loop() { // AsyncWebServer does not need manual .loop() delay(500); digitalWrite(15, HIGH); digitalWrite(16, LOW); delay(500); digitalWrite(15, LOW); digitalWrite(16, HIGH); } ``` ### JaveScript 程式 ``` <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>ESP32 OTA</title> <style> body { font-family: sans-serif; text-align: center; padding-top: 30px; } input, button { margin: 8px; padding: 6px 12px; font-size: 14px; } #bar { height: 20px; width: 0%; background: #4caf50; transition: width 0.2s; } #progress { width: 300px; border: 1px solid #999; margin: auto; height: 20px; } #status { margin-top: 12px; font-weight: bold; font-size: 16px; } .success { color: green; } .error { color: red; } .info { color: #333; } #logArea { width: 90%; max-width: 600px; margin: 20px auto; padding: 10px; border: 1px solid #ccc; height: 200px; overflow-y: auto; text-align: left; background: #f9f9f9; font-family: monospace; font-size: 13px; } </style> </head> <body> <h2>ESP32 OTA Upload</h2> <input type="text" id="token" placeholder="Token" value="123456"><br> <input type="file" id="firmware"><br> <button onclick="upload()">Upload</button> <button onclick="downloadLog()">下載 Log</button> <div id="progress"> <div id="bar"></div> </div> <div id="status" class="info">尚未上傳</div> <pre id="logArea"></pre> <script> class OTAUploader { constructor(tokenInputId, fileInputId, statusId, barId, logAreaId) { this.tokenInput = document.getElementById(tokenInputId); this.fileInput = document.getElementById(fileInputId); this.status = document.getElementById(statusId); this.progressBar = document.getElementById(barId); this.logArea = document.getElementById(logAreaId); this.logBuffer = []; this.ws = null; this.data = null; this.crc = 0; this.offset = 0; this.chunkSize = 1024; } appendLog(msg) { const now = new Date().toISOString(); const fullMsg = `[${now}] ${msg}`; this.logBuffer.push(fullMsg); this.logArea.textContent += fullMsg + "\n"; this.logArea.scrollTop = this.logArea.scrollHeight; console.log(msg); } updateStatus(text, type = "info") { this.status.className = type; this.status.innerText = text; this.appendLog(text); } downloadLog() { const blob = new Blob([this.logBuffer.join('\n')], { type: "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `esp32_log_${new Date().toLocaleTimeString()}.txt`; a.click(); URL.revokeObjectURL(url); } crc32(buf) { let table = window.crcTable || (window.crcTable = (() => { let c, t = []; for (let n = 0; n < 256; n++) { c = n; for (let k = 0; k < 8; k++) c = c & 1 ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); t[n] = c; } return t; })()); let crc = 0 ^ (-1); for (let i = 0; i < buf.length; i++) crc = (crc >>> 8) ^ table[(crc ^ buf[i]) & 0xFF]; return (crc ^ (-1)) >>> 0; } sendChunk() { if (this.offset < this.data.length) { const slice = this.data.slice(this.offset, this.offset + this.chunkSize); this.ws.send(slice); this.offset += this.chunkSize; this.progressBar.style.width = Math.floor(this.offset / this.data.length * 100) + '%'; this.updateStatus(`📤 傳送中:${this.offset}/${this.data.length}`, "info"); } else { this.ws.send(JSON.stringify({ cmd: "crc", crc32: "0x" + this.crc.toString(16) })); this.updateStatus("✅ 傳輸完成,已送出 CRC32,等待驗證結果...", "info"); } } handleMessage(e) { this.appendLog("WS 回應: " + e.data); try { const json = JSON.parse(e.data); if (json.error) { this.updateStatus("❌ 錯誤:" + json.error, "error"); } else if (json.status === "auth_ok") { this.updateStatus("🔄 授權成功,開始傳送...", "green"); this.sendChunk(); } else if (json.status === "ack") { this.sendChunk(); } else if (json.status) { this.updateStatus("✅ 成功:" + json.status, "success"); } else { this.updateStatus("ℹ️ 回應:" + e.data, "info"); } } catch { this.updateStatus("⚠️ 非 JSON 訊息:" + e.data, "info"); } } handleError(err) { console.error("WebSocket error:", err); this.updateStatus("❌ WebSocket 連線錯誤", "error"); } upload() { const file = this.fileInput.files[0]; const token = this.tokenInput.value; if (!file || !token) return alert("請選擇檔案並輸入 token"); this.updateStatus("📁 開始讀取檔案...", "info"); const reader = new FileReader(); reader.onload = () => { this.data = new Uint8Array(reader.result); this.crc = this.crc32(this.data); this.offset = 0; this.ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws"); this.ws.binaryType = "arraybuffer"; this.ws.onopen = () => { this.updateStatus("🌐 連線成功,開始驗證...", "info"); this.ws.send(JSON.stringify({ cmd: "auth", token: token, size: this.data.length })); }; this.ws.onmessage = e => this.handleMessage(e); this.ws.onerror = err => this.handleError(err); }; reader.readAsArrayBuffer(file); } } // ✅ 宣告實例並對應 HTML ID const uploader = new OTAUploader("token", "firmware", "status", "bar", "logArea"); // ✅ 讓 HTML 中 button onclick 可以用 function upload() { uploader.upload(); } function downloadLog() { uploader.downloadLog(); } </script> </body> </html> ``` ### 執行畫面 ![image](https://hackmd.io/_uploads/BJZnoYldlg.png)