### 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>
```
### 執行畫面
