2026年2月15日 星期日

MicroPython 學習筆記 : ESP32-S3 CAM 開發板測試 (八)

在前一篇的 Generic 韌體測試後得到一個結論 : 放棄 TF 卡吧! 反正 ESP32-S3 CAM 是 N16R8, 有巨量的 Flash 儲存空間, 所以還是回到又新又好用的 cnadler86 的 2026 年 Freenove 專版韌體探索其它應用. 本篇旨在紀錄燒回此版韌體後, 修改串流伺服器程式, 增加一個 "拍照存檔" 按鈕後的測試結果, 其實就是前面筆記 (五) 的後續修正版. 

燒錄韌體方法參考前面筆記 (五), 此處不再贅述, 其實就是洗掉重燒兩個指令而已 (此處 UART 端口之 COM 埠號要自行修改) : 

esptool --chip esp32s3 --port COM4 erase_flash
esptool --chip esp32s3 --port COM4 write_flash -z 0 firmware.bin

串流伺服器程式修改如下, 主要是增加了一個拍照存檔按鈕, 並且網頁文字改為繁體中文 :

import network
import socket
import camera
import time
import machine
import gc
import os
import errno

# ==========================================
# 0. 全域變數
# ==========================================
cam = None  # 用來存放相機物件

# ==========================================
# 1. 設定 Wi-Fi
# ==========================================
SSID = "ASUS-RT-AX3000"       
PASSWORD = "a5572056"   

print("========================================")
print("📸 Vibe Cam 2026 - with Save Function")
print("========================================")

# ==========================================
# 2. 連線 Wi-Fi
# ==========================================
def connect_wifi():
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    if not wlan.isconnected():
        print(f"🔗 正在連接 Wi-Fi: {SSID}...", end="")
        wlan.connect(SSID, PASSWORD)
        retry = 0
        while not wlan.isconnected():
            time.sleep(1)
            print(".", end="")
            retry += 1
            if retry > 20:
                print("\n❌ Wi-Fi 連線逾時!")
                return None
    ip = wlan.ifconfig()[0]
    print(f"\n✅ Wi-Fi 已連線! IP: http://{ip}")
    return ip

# ==========================================
# 3. 相機初始化 (保持您原本的寫法)
# ==========================================
def init_camera():
    global cam
    gc.collect()
    try:
        # 1. 建立相機物件
        cam = camera.Camera()
        # Freenove 韌體通常使用無參數 init
        try:
            cam.init()
        except:
            cam.init(0)
        
        # 2. 設定參數 (JPEG, VGA)
        time.sleep(0.5)
        # 嘗試設定參數 (相容不同版本的 Freenove 韌體)
        try:
            cam.reconfigure(pixel_format=camera.PixelFormat.JPEG, 
                          frame_size=camera.FrameSize.VGA)
        except:
            # 如果 reconfigure 失敗,嘗試舊版寫法
            try:
                cam.framesize(camera.FrameSize.VGA)
                cam.pixformat(camera.PixelFormat.JPEG)
            except: pass
        
        print("📷 相機初始化成功")
        return True
    except Exception as e:
        print(f"⚠️ 相機初始化失敗: {e}")
        return False

# ==========================================
# 4. 網頁 HTML (新增存檔按鈕)
# ==========================================
html = """<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>Vibe Cam 2026</title>
    <style>
        body { font-family: sans-serif; background: #222; color: #fff; text-align: center; padding: 20px; }
        .btn { padding: 15px 30px; border: none; border-radius: 5px; font-size: 1.2rem; cursor: pointer; margin: 10px; color: white; }
        .btn-blue { background: #007bff; }
        .btn-green { background: #28a745; }
        img { width: 100%; max-width: 640px; border: 2px solid #555; background: #000; min-height: 240px; }
    </style>
</head>
<body>
    <h1>🏠 Vibe Cam 2026</h1>
    <img id="stream" src="" alt="等待影像...">
    <br>
    <button class="btn btn-blue" onclick="toggle()">啟動/停止 串流</button>
    <button class="btn btn-green" onclick="savePhoto()">📸 拍照存檔</button>
    
    <div id="status" style="color:#aaa; margin-top:10px;">準備好了</div>

    <script>
        var active = false;
        var img = document.getElementById("stream");
        var stat = document.getElementById("status");
        
        function toggle() { 
            active = !active; 
            if(active) {
                stat.innerText = "串流進行中 ...";
                load(); 
            } else {
                stat.innerText = "串流已停止";
            }
        }

        // 新增:拍照存檔函式
        function savePhoto() {
            stat.innerText = "照片儲存到 Flash...";
            fetch('/save').then(response => response.text()).then(text => {
                alert(text);
                stat.innerText = text;
            }).catch(error => {
                alert("Save Failed!");
                stat.innerText = "Error";
            });
        }
        
        function load() {
            if(!active) return;
            img.src = "/capture?t=" + new Date().getTime();
            
            img.onload = () => { if(active) load(); };
            
            img.onerror = () => { 
                stat.innerText = "Error (Retrying...)";
                if(active) setTimeout(load, 500); 
            };
        }
    </script>
</body>
</html>
"""

# ==========================================
# 5. 主伺服器 (新增 /save 路由)
# ==========================================
def start_server():
    global cam
    
    # 先連網
    ip = connect_wifi()
    if not ip: return

    # 再開相機
    if not init_camera():
        print("❌ 無法啟動伺服器,因為相機失敗")
        return

    # 建立 Socket
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(('', 80))
    s.listen(5)
    s.settimeout(0.5) 
    
    print("✅ 伺服器就緒。請打開瀏覽器,並觀察 Shell 的輸出。")

    try:
        while True:
            try:
                conn, addr = s.accept()
                conn.settimeout(None)
                request = conn.recv(1024).decode()
                
                # --- 路由 1: 取得影像 ---
                if "GET /capture" in request:
                    buf = cam.capture()
                    if buf and len(buf) > 0:
                        conn.send(b'HTTP/1.1 200 OK\r\nContent-Type: image/jpeg\r\n')
                        conn.send(f'Content-Length: {len(buf)}\r\n\r\n'.encode())
                        conn.send(buf)
                    else:
                        conn.send(b'HTTP/1.1 500 Error\r\n\r\n')
                
                # --- 路由 2: 拍照存檔 (新增功能) ---
                elif "GET /save" in request:
                    print("📸 正在存檔...")
                    buf = cam.capture()
                    if buf and len(buf) > 0:
                        # 產生檔名 (使用時間戳記)
                        filename = f"photo_{time.ticks_ms()}.jpg"
                        with open(filename, "wb") as f:
                            f.write(buf)
                        
                        msg = f"Saved: {filename} ({len(buf)//1024} KB)"
                        print(f"💾 {msg}")
                        conn.send(b'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n' + msg.encode())
                    else:
                        conn.send(b'HTTP/1.1 500 Error\r\n\r\nCamera Failed')

                # --- 路由 3: 自毀程式 (保留您的功能) ---
                elif "GET /nuke" in request:
                    print("☢️ DELETE main.py...")
                    try: os.remove('main.py')
                    except: pass
                    conn.send(b'HTTP/1.1 200 OK\r\n\r\nRebooting...')
                    time.sleep(1)
                    machine.reset()

                # --- 路由 4: 首頁 ---
                else:
                    conn.send(b'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n')
                    conn.send(html.encode())
                
                conn.close()
                gc.collect()

            except OSError as e:
                if e.args[0] == 110 or e.args[0] == 116: continue

    except KeyboardInterrupt:
        print("\n🛑 停止中...")
        s.close()
        if cam:
            try: cam.deinit()
            except: pass
            print("相機已釋放")

# 啟動!
start_server()

輸出結果如下 : 

========================================
📸 Vibe Cam 2026 - with Save Function
========================================
True
🔗 正在連接 Wi-Fi: ASUS-RT-AX3000.....
✅ Wi-Fi 已連線! IP: http://192.168.50.111
📷 相機初始化成功
✅ 伺服器就緒。請打開瀏覽器,並觀察 Shell 的輸出。
44
2216
44
2216
43
25
18212
43
.....




按拍照存檔鈕 : 





按停止串流 : 




注意, 此程式設有自毀裝置, 當想要刪除串流程式 main.py 時, 可輸入網址後面加 /nuke 即可刪除 main.py, 因為此程式為一個 while 迴圈, 執行中可能無法在 Thonny 的左下角視窗刪除 main.py (會一直 Busy). 

MicroPython 學習筆記 : ESP32-S3 CAM 開發板測試 (七)

在前一篇測試中, 使用 cnadler86 的 2026 新版 Freenove 韌體測試拍照功能 OK, 但 TF 卡也是與 shariltumin 的 2020 年版韌體一樣無法掛載, Gemini Pro 建議我試試看 cnadler86 的 Generic 版韌體, 本篇旨在紀錄此 Generic 版韌體測試過程, 先說結論 : 拍照 OK (但程式較複雜, 花了很多時間在找正確的參數名稱), TF 卡仍舊無法掛載. 

本系列之前的測試文章參考 :


首先下載 Generic 版韌體 mpy_cam-v1.27.0-ESP32_GENERIC_S3-SPIRAM_OCT.zip :





解壓縮後燒錄到 ESP32-S3 CAM :

D:\ESP32-S3-CAM>esptool --chip esp32s3 --port COM4 erase_flash    
esptool.py v4.6.2
Serial port COM4
Connecting..............
Chip is ESP32-S3 (revision v0.2)
Features: WiFi, BLE
Crystal is 40MHz
MAC: e0:72:a1:d7:dd:d4
Uploading stub...
Running stub...
Stub running...
Erasing flash (this may take a while)...
Chip erase completed successfully in 5.3s
Hard resetting via RTS pin...

D:\ESP32-S3-CAM>esptool --chip esp32s3 --port COM4 write_flash -z 0 firmware.bin
esptool.py v4.6.2
Serial port COM4
Connecting..............
Chip is ESP32-S3 (revision v0.2)
Features: WiFi, BLE
Crystal is 40MHz
MAC: e0:72:a1:d7:dd:d4
Uploading stub...
Running stub...
Stub running...
Configuring flash size...
Flash will be erased from 0x00000000 to 0x001dbfff...
Compressed 1948272 bytes to 1238576...
Wrote 1948272 bytes (1238576 compressed) at 0x00000000 in 108.2 seconds (effective 144.0 kbit/s)...
Hash of data verified.

Leaving...
Hard resetting via RTS pin...

MPY: soft reboot
MicroPython v1.27.0-dirty on 2026-01-12; Generic ESP32S3 module with Octal-SPIRAM with ESP32S3
Type "help()" for more information.

接下來的測試就很煩人, 因為 Gemini 生成的測試程式碼執行時一直出現參數名稱不正確問題, 來回更改甚至使用暴力猜測, 終於找到可以成功拍照的參數配置 : 

import camera
import machine
import os
import time
import gc

# ==========================================
# 🚀 Vibe Cam - 完美完全體 (Perfect Edition)
# ==========================================

gc.collect()
print("========================================")
print("⚙️ 初始化硬體 (Perfect Mode)")
print("========================================")

# 變數準備
buf = None
save_path = ""

# --- 步驟 1: 啟動相機 ---
try:
    print("📷 [Step 1] 啟動相機...")
    
    # 這是我們驗證過 100% 正確的參數
    cam = camera.Camera(
        data_pins=[11, 9, 8, 10, 12, 18, 17, 16],
        xclk_pin=15,
        pclk_pin=13,
        vsync_pin=6,
        href_pin=7,
        sda_pin=4,
        scl_pin=5,
        # 設定直接放這裡
        xclk_freq=20000000,
        pixel_format=camera.PixelFormat.JPEG,
        frame_size=camera.FrameSize.VGA
    )
    
    print("🔌 驅動啟動中...")
    try: cam.init()
    except: pass

    time.sleep(2.0) # 暖機

    # --- 步驟 2: 拍照 ---
    print("📸 [Step 2] 拍攝中...")
    cam.capture() # 空拍
    time.sleep(0.1)
    buf = cam.capture() # 正拍
    
    print(f"📦 影像已暫存於 RAM: {len(buf)} bytes")
    
    # --- 步驟 3: 關閉相機 (釋放資源) ---
    print("💤 [Step 3] 釋放相機資源...")
    cam.deinit()
    del cam
    gc.collect()

except Exception as e:
    print(f"❌ 相機錯誤: {e}")
    import sys
    sys.exit()

# --- 步驟 4: 掛載 SD 卡 (修正參數名) ---
if buf and len(buf) > 0:
    print("💾 [Step 4] 正在掛載 SD 卡...")
    try:
        try: os.umount('/sd')
        except: pass
        
        # 【關鍵修正】Generic 韌體使用 SPI 命名法對應 SDMMC
        # CLK(39) -> sck
        # CMD(38) -> mosi
        # D0 (40) -> miso
        sd = machine.SDCard(slot=1, width=1, 
                            sck=machine.Pin(39), 
                            mosi=machine.Pin(38), 
                            miso=machine.Pin(40))
                            
        os.mount(sd, '/sd')
        print("✅ SD 卡掛載成功!")
        print(f"📦 容量資訊: {os.statvfs('/sd')}")
        save_path = "/sd/"
        
    except Exception as e:
        print(f"⚠️ SD 卡掛載失敗 ({e})")
        print("👉 改存內部記憶體")
        save_path = "" 

    # --- 步驟 5: 存檔 ---
    try:
        filename = f"{save_path}vibe_perfect.jpg"
        print(f"📝 [Step 5] 寫入檔案: {filename} ...")
        
        with open(filename, "wb") as f:
            f.write(buf)
            
        print("----------------------------------------")
        print(f"🎉🎉🎉 完美勝利!照片已存入{'SD 卡' if save_path=='/sd/' else '內部記憶體'}! 🎉🎉🎉")
        print(f"📂 檔案位置: {filename}")
        print("----------------------------------------")
        
        if save_path == "/sd/":
             print(f"📂 SD 卡檔案列表: {os.listdir('/sd')}")
             
    except Exception as e:
        print(f"❌ 寫入失敗: {e}")
else:
    print("❌ 拍照失敗,無數據")

print("========================================")

Generic 版韌體自由度最大, 但需要傳入所有必要參數, 而文件並位充分揭露正確的參數名稱, 此程式中的參數名是 Gemini 反覆測試才找出來的, 結果拍照成功, 但 TF 卡還是無法掛載, 所以照片是存在 Flash 記憶體中 : 

========================================
⚙️ 初始化硬體 (Perfect Mode)
========================================
📷 [Step 1] 啟動相機...
🔌 驅動啟動中...
📸 [Step 2] 拍攝中...
<memoryview>
📦 影像已暫存於 RAM: 19893 bytes
💤 [Step 3] 釋放相機資源...
💾 [Step 4] 正在掛載 SD 卡...
⚠️ SD 卡掛載失敗 (invalid config: SDMMC slot with SPI pin arguments)
👉 改存內部記憶體
📝 [Step 5] 寫入檔案: vibe_perfect.jpg ...
19893
----------------------------------------
🎉🎉🎉 完美勝利!照片已存入內部記憶體! 🎉🎉🎉
📂 檔案位置: vibe_perfect.jpg
----------------------------------------
========================================




關於 TF 卡問題, Gemini 分析原因如下 : 

用 clk/cmd/d0 時報錯 extra keyword arguments (多餘參數), 用 sck/mosi/miso 時報錯 invalid config: SDMMC slot with SPI pin arguments (SDMMC 插槽不能用 SPI 參數), 這兩條線索結合起來真相只有一個 : 這版韌體的 machine.SDCard 在 slot=1 (SDMMC 模式) 下很可能不接受 machine.Pin 物件, 或者參數名稱需要微調. 

為了避免盲測, 準備了下面這個  "SD 卡偵探程式" 來掃描 :

import machine
import os
import time

print("========================================")
print("💾 SD 卡終極偵探 (SD Detective)")
print("========================================")

# 定義掛載測試函式
def try_mount(name, **kwargs):
    print(f"\n👉 測試組合: [{name}]")
    try:
        # 嘗試卸載
        try: os.umount('/sd')
        except: pass
        
        # 建立物件
        print(f"   參數: {kwargs}")
        sd = machine.SDCard(**kwargs)
        
        # 掛載
        os.mount(sd, '/sd')
        
        # 驗證
        stat = os.statvfs('/sd')
        capacity = (stat[0] * stat[2]) / 1024 / 1024
        print(f"   🎉 成功!容量: {capacity:.2f} MB")
        print(f"   ✅ 冠軍組合是: {name}")
        return True
    except Exception as e:
        print(f"   ❌ 失敗: {e}")
        return False

# --- 開始測試 ---

# 組合 A: 使用整數 (Int) 而不是 Pin 物件
# 有些韌體討厭 machine.Pin(39),只喜歡 39
params_A = {
    "slot": 1,
    "width": 1,
    "clk": 39,
    "cmd": 38,
    "d0": 40
}

# 組合 B: 使用 _pin 後綴 (既然相機都要加 _pin,SD 卡可能也要?)
params_B = {
    "slot": 1,
    "width": 1,
    "clk_pin": 39,
    "cmd_pin": 38,
    "d0_pin": 40
}

# 組合 C: 什麼都不加 (賭它會自動吃到板子的預設值)
# 雖然機率低,但 Generic 韌體有時會從 partition table 讀取設定
params_C = {
    "slot": 1,
    "width": 1
}

# 執行掃描
if try_mount("A_Int_Params", **params_A):
    pass
elif try_mount("B_Pin_Suffix", **params_B):
    pass
elif try_mount("C_Default", **params_C):
    pass
else:
    print("\n💀 全部失敗。")
    print("這代表這版韌體的 SDMMC 驅動鎖定了預設腳位,無法透過 Python更改。")
    print("💡 但別擔心!我們已經有完美的內部記憶體存檔方案了!")

print("========================================")

偵測結果如下 :

========================================
💾 SD 卡終極偵探 (SD Detective)
========================================

👉 測試組合: [A_Int_Params]
   參數: {'cmd': 38, 'clk': 39, 'width': 1, 'd0': 40, 'slot': 1}
   ❌ 失敗: extra keyword arguments given

👉 測試組合: [B_Pin_Suffix]
   參數: {'clk_pin': 39, 'cmd_pin': 38, 'width': 1, 'slot': 1, 'd0_pin': 40}
   ❌ 失敗: extra keyword arguments given

👉 測試組合: [C_Default]
   參數: {'slot': 1, 'width': 1}
   ❌ 失敗: 16

💀 全部失敗。
這代表這版韌體的 SDMMC 驅動鎖定了預設腳位,無法透過 Python更改。
💡 但別擔心!我們已經有完美的內部記憶體存檔方案了!
========================================

所以只好放棄用 Generic 版韌體掛載 TF 卡的想法了. 

Gemini 做出的最終鑑識報告摘要如下 :
  • SD 卡死因 :
    測試 A, B, C 全部失敗, 證實了這版 Generic 韌體的 SDMMC 驅動 (slot=1) 是寫死 (Hardcoded) 的, 它只接受 ESP32-S3 的原廠預設腳位, 不開放透過 Python 修改腳位.
  • 不幸的巧合 :
    這片 Freenove 板子為了走線方便使用了特殊的腳位 (38/39/40), 這與原廠預設不同, 這就是為什麼掛載一直失敗的原因. 
它的推斷也許不是 100% 正確, 但也沒必要繼續花時間鑽研, 因為 Generic 韌體的程式碼太複雜, 還是灌回前一篇的 Freenove 專用版為宜, 程式簡潔好懂. 

最後測試一下此版韌體的串流伺服器功能 : 

import network
import socket
import camera
import time
import machine
import gc
import os

# ==========================================
# 0. 全域設定
# ==========================================
SSID = "您的WiFi名稱"       
PASSWORD = "您的WiFi密碼"  

# 標記這是 Freenove S3 (Generic Firmware)
print("========================================")
print("🚀 Vibe Cam Streamer - Freenove Edition")
print("========================================")

# ==========================================
# 1. 連線 Wi-Fi
# ==========================================
def connect_wifi():
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    if not wlan.isconnected():
        print(f"🔗 正在連接 Wi-Fi: {SSID}...", end="")
        wlan.connect(SSID, PASSWORD)
        retry = 0
        while not wlan.isconnected():
            time.sleep(1)
            print(".", end="")
            retry += 1
            if retry > 15:
                print("\n❌ Wi-Fi 連線逾時!請檢查密碼。")
                return None
    ip = wlan.ifconfig()[0]
    print(f"\n✅ Wi-Fi 已連線!")
    print(f"👉 串流網址: http://{ip}")
    print(f"👉 拍照存檔: http://{ip}/save")
    return ip

# ==========================================
# 2. 相機初始化 (使用我們驗證成功的黃金參數)
# ==========================================
def init_camera():
    gc.collect()
    try:
        print("📷 正在啟動相機...")
        # 【我們辛苦找出的正確參數】
        cam = camera.Camera(
            data_pins=[11, 9, 8, 10, 12, 18, 17, 16],
            xclk_pin=15,
            pclk_pin=13,
            vsync_pin=6,
            href_pin=7,
            sda_pin=4,
            scl_pin=5,
            # 頻率與格式
            xclk_freq=20000000,
            pixel_format=camera.PixelFormat.JPEG,
            frame_size=camera.FrameSize.VGA
        )
        
        # 啟動驅動
        try: cam.init()
        except: pass
        
        print("✅ 相機初始化成功!")
        return cam
    except Exception as e:
        print(f"⚠️ 相機初始化失敗: {e}")
        return None

# ==========================================
# 3. 網頁 HTML
# ==========================================
html = """<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>Vibe Cam Stream</title>
    <style>
        body { font-family: sans-serif; background: #222; color: #fff; text-align: center; padding: 20px; }
        .btn { background: #28a745; color: white; padding: 15px 30px; border: none; border-radius: 5px; font-size: 1.2rem; cursor: pointer; margin: 10px; }
        .btn-stop { background: #dc3545; }
        img { width: 100%; max-width: 640px; border: 2px solid #555; background: #000; min-height: 240px; }
    </style>
</head>
<body>
    <h1>📡 Freenove S3 Streamer</h1>
    <img id="stream" src="" alt="點擊 Start 開始串流">
    <br>
    <button class="btn" onclick="startStream()">Start Stream</button>
    <button class="btn btn-stop" onclick="stopStream()">Stop</button>
    <br>
    <button class="btn" style="background:#17a2b8" onclick="savePhoto()">📸 拍照存檔</button>
    <div id="status" style="color:#aaa; margin-top:10px;">Ready</div>

    <script>
        var active = false;
        var img = document.getElementById("stream");
        var stat = document.getElementById("status");
        
        function startStream() {
            if(active) return;
            active = true;
            stat.innerText = "Streaming...";
            load();
        }

        function stopStream() {
            active = false;
            stat.innerText = "Stopped";
        }

        function savePhoto() {
            fetch('/save').then(response => response.text()).then(text => {
                alert(text);
            });
        }
        
        function load() {
            if(!active) return;
            img.src = "/capture?t=" + new Date().getTime();
            img.onload = () => { if(active) load(); };
            img.onerror = () => { 
                if(active) setTimeout(load, 500); 
            };
        }
    </script>
</body>
</html>
"""

# ==========================================
# 4. 主伺服器
# ==========================================
def start_server():
    # 1. 先開相機 (確保硬體就緒)
    cam = init_camera()
    if not cam: return

    # 2. 再連網路
    ip = connect_wifi()
    if not ip: return

    # 3. 建立 Socket
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(('', 80))
    s.listen(5)
    s.settimeout(0.5) 

    print("\n✅ 伺服器啟動中... (按 Ctrl+C 可停止)")

    try:
        while True:
            try:
                conn, addr = s.accept()
                conn.settimeout(None)
                request = conn.recv(1024).decode()
                
                # --- [路由] 取得影像 ---
                if "GET /capture" in request:
                    buf = cam.capture()
                    if buf:
                        conn.send(b'HTTP/1.1 200 OK\r\nContent-Type: image/jpeg\r\n')
                        conn.send(f'Content-Length: {len(buf)}\r\n\r\n'.encode())
                        conn.send(buf)
                    else:
                        conn.send(b'HTTP/1.1 500 Error\r\n\r\n')

                # --- [路由] 拍照存檔 (存入內部記憶體) ---
                elif "GET /save" in request:
                    print("📸 正在拍照存檔...")
                    buf = cam.capture()
                    if buf:
                        # 檔名加上時間戳記 (模擬) 或流水號
                        filename = f"photo_{time.ticks_ms()}.jpg"
                        with open(filename, "wb") as f:
                            f.write(buf)
                        msg = f"Saved to {filename} ({len(buf)} bytes)"
                        print(f"💾 {msg}")
                        conn.send(b'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n' + msg.encode())
                    else:
                        conn.send(b'HTTP/1.1 500 Error\r\n\r\nFailed')

                # --- [路由] 首頁 ---
                else:
                    conn.send(b'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n')
                    conn.send(html.encode())
                
                conn.close()

            except OSError as e:
                if e.args[0] == 110 or e.args[0] == 116: continue

    except KeyboardInterrupt:
        print("\n🛑 停止伺服器...")
        s.close()
        cam.deinit()
        print("相機已釋放")

# 啟動!
start_server()

執行結果如下 :

========================================
🚀 Vibe Cam Streamer - Freenove Edition
========================================
📷 正在啟動相機...
✅ 相機初始化成功!
True
🔗 正在連接 Wi-Fi: ASUS-RT-AX3000.....
✅ Wi-Fi 已連線!
👉 串流網址: http://192.168.50.111
👉 拍照存檔: http://192.168.50.111/save

✅ 伺服器啟動中... (按 Ctrl+C 可停止)

25
17946
43
25
17991
43
25
....

串流結果 OK : 





按拍照存檔會將照片以 photo_xxxxx.jpg 儲存於 Flash :




總之, Generic 版韌體仍無法掛載 TF 卡, 做完測試還是灌回前一篇的 Freenove 專版. 

2026年2月14日 星期六

MicroPython 學習筆記 : ESP32-S3 CAM 開發板測試 (六)

本篇是在前一篇測試使用 cnadler86 的 2026 新版韌體可成功拍照的基礎上, 進一步測試是否可以成功掛載 TF 卡. 

本系列之前的測試文章參考 :


ESP32-S3 CAM 開發板背後有一個 TF 卡槽, 使用的是比 SPI 介面快的 SDMMC 介面, ESP32-S3 內建的 SDMMC 主機控制器 (Host Controller) 支援 1-bit, 4-bit 甚至 8-bit 並行傳輸, 1-bit 模式只需要三根腳 : CMD (指令線, GPIO38), CLK (時脈線, GPIO39), D0 (資料線 GPIO40); 而 SPI 則需要四根腳 : CS, MOSI, MISO, CLK. 

SDMMC 是 CPU 與 SD 卡的溝通管道 (介面/協議), 而 SDHC/SDXC 則是儲存設備 SD 卡的規格 (容量等級), SD 卡規格有兩種 : 
  • SDHC (2GB - 32GB) : 出廠預設為 FAT32 
  • SDXC (64GB - 2TB) : 出廠預設為 exFAT 
ESP32-S3 CAM 的 SDMMC 控制器支援 SDXC 高容量標準, 但受限於 MicroPython 的 vfs (虛擬檔案系統) 模組只支援 FAT 或 FAT32 格式, 所以如果插上 64GB 以上的 TF 卡, MicroPython 會報錯 (OSError: [Errno 19] ENODEV). 因此 ESP32-S3 CAM 開發板可用的 TF 卡容量最大是 32GB, 建議使用 8GB, 這是 ESP32 的神卡, 對 ESP32 開發者來說, 手上有 8GB 卡其實比 64GB 卡還珍貴, 原因是 :
  • 原生 FAT32 支援 :
    8GB 卡屬於 SDHC 標準, 出廠就是 FAT32 格式, 插上去 MicroPython 直接能掛載 (os.mount), 完全不需要找工具格式化.
  • 備份方便 :
    映像檔小, 如果您想備份整張卡的系統與資料, 備份 8GB 只要幾分鐘.
  • 速度匹配 :
    ESP32 的 SDMMC 介面寫入速度極限約在 1MB/s - 4MB/s 之間, 再快的卡也跑不滿, 所以舊的 8GB 卡 (通常是 Class 4 或 Class 10) 剛好能物盡其用.
對於 ESP32-S3 CAM 這種物聯網裝置來說, 8GB 其實已是海量的空間, 因為 ESP32 的鏡頭模組 (ov2640/ov5640) 產生的檔案遠比手機小,  8GB 卡片足夠進行數週甚至數月的監控記錄. 由於 ESP32 沒有 H.264 硬體影片編碼器, 它錄影的方式其實是把一張張 JPEG 照片串起來存成 .avi (MJPEG 格式), 以 VGA (640x480) 格式錄影的話, 8GB 大約可錄 3.5 ~ 4 小時 (15 FPS, 0.6 MB/秒); 以 SVGA (800x600) 格式錄影可錄約 2 小時 (10 FPS, 1.0 MB/秒). 

我最近買的 3 片 ESP32-S3 CAM 開發板經 Gemini 判定確實是 Freenove 相容開發板 : 





這塊板子的設計非常特殊, 與市面上常見的 ESP32-S3 CAM 不同, 主要特徵如下 :
  • 獨特的絲印圖例 : 
    板子背面有一組非常特殊的符號說明 :
    - : Camera (代表相機用腳位)
    ~ : SD Card (代表 SD 卡用腳位)
    * : PSRAM (代表記憶體用腳位)
    這是 Freenove 這款板子 (以及它的代工廠/複製品) 獨有的標示方式, 一般的 ESP32-S3 開發板不會這樣特地標註這三種符號. 
  • SD 卡腳位確認 (GPIO 38, 39, 40) :
    GPIO 38, 39, 40 旁邊都有一個波浪號 ~, 對照背面的圖例, 波浪號代表 SD Card, 這證實了這塊板子的 SD 卡確實是連接在 38 (CMD), 39 (CLK), 40 (D0) 上, 它只拉出了 D0 一條資料線, 沒有 D1, D2, D3. 
  • 模組型號確認 (N16R8) :
    金屬遮罩上的晶片型號清楚寫著 ESP32-S3-N16R8, N16 表示程式儲存空間有 16MB Flash, 而 R8 表示執行記憶體有 8MB (Octal PSRAM), 所以下載 MicroPython 韌體時必須選擇支援 SPIRAM / Octal SPIRAM 的版本, 否則這 8MB 的記憶體會無法使用, 相機跑到高解析度時就會記憶體不足. 
TF 卡 (Micro SD) 使用前須先用 PC 將其格式化為 FAT32 格式, 這是 MicroPython 唯一支援的格式 (它只認得最古老最簡單的 FAT32, 如果是 exFAT 或 NTFS 會讀不到). 斷電後將已格式化為 FAT32 的 TF 卡插入開發板再通電, 然後執行下列程式 :

import camera
import machine
import os
import time
import gc

# --- 1. 掛載 TF 卡 (如果還沒掛載) ---
try:
    # 檢查是否已經有 /sd 路徑,沒有才掛載
    try:
        os.listdir('/sd')
        print("✅ TF 卡已掛載")
    except OSError:
        print("💾 嘗試掛載 TF 卡...")
        sd = machine.SDCard(slot=1, width=1, clk=machine.Pin(39), cmd=machine.Pin(38), d0=machine.Pin(40))
        os.mount(sd, '/sd')
        print("✅ 掛載成功!")
except Exception as e:
    print(f"⚠️ TF 卡掛載失敗,將存檔至內存: {e}")
    save_path = "" # 存到根目錄
else:
    save_path = "/sd/" # 存到 SD 卡

# --- 2. 執行拍照 ---
gc.collect()
cam = camera.Camera()
cam.init()

try:
    # 設定格式
    cam.reconfigure(pixel_format=camera.PixelFormat.JPEG, frame_size=camera.FrameSize.SVGA) # 試試 SVGA (800x600)
    time.sleep(1.5) # 等暖機
    
    # 拍照
    print("📸 拍攝中...")
    cam.capture() # 清緩存
    buf = cam.capture()
    
    # --- 3. 存檔 ---
    filename = f"{save_path}vibe_photo_{time.ticks_ms()}.jpg"
    with open(filename, "wb") as f:
        f.write(buf)
        
    print(f"🎉 照片已儲存至: {filename}")
    print(f"大小: {len(buf)/1024:.2f} KB")

except Exception as e:
    print(f"❌ 拍攝失敗: {e}")
finally:
    cam.deinit()

此程式會先試圖掛載 TF 卡然後進行拍照, 若掛載成功, 照片會儲存在 TF 卡內, 否則存在 Flash 內, 結果 TF 卡掛載失敗 :

💾 嘗試掛載 TF 卡...
⚠️ TF 卡掛載失敗,將存檔至內存: extra keyword arguments given
📸 拍攝中...
<memoryview>
24616
🎉 照片已儲存至: vibe_photo_23376332.jpg
大小: 24.04 KB

詢問 Gemini 可能原因, 它認為韌體作者 cnadler86 可能在編譯時就把這塊板子特殊的 SD 卡腳位(38, 39, 40) 寫死在韌體核心裡了, 所以不需要傳入 clk, cmd, 與 d0 參數, 傳入參數只要保留 slot 和 width 即可, 建議掛載程式修改為 :

import machine
import os

print("========================================")
print("💾 Freenove S3 - TF 卡掛載測試 (Freenove 專用版)")
print("========================================")

try:
    # 修正重點:
    # 既然是 Freenove 專用韌體,腳位已經內建好了。
    # 我們只要告訴它:「用 Slot 1 (SDMMC),跑 1-bit 模式」即可。
    sd = machine.SDCard(slot=1, width=1)
    
    # 掛載
    try:
        os.mount(sd, '/sd')
        print("✅ 掛載成功!(/sd)")
    except OSError as e:
        if "already mounted" in str(e):
            print("⚠️ 提示: 已經掛載過了,無需重複掛載")
        else:
            raise e 

    # 驗證容量
    stat = os.statvfs('/sd')
    total_mb = (stat[0] * stat[2]) / 1024 / 1024
    print(f"📦 卡片容量: {total_mb:.2f} MB")
    print("📂 檔案列表:", os.listdir('/sd'))

except Exception as e:
    print(f"❌ 掛載失敗: {e}")
    print("\n【除錯提示】")
    print("1. 確保卡片格式是 FAT32。")
    print("2. 確保卡片已插緊 (Freenove 的卡槽有時需要推到底)。")
    print("3. 如果還是失敗,請嘗試將 width=1 改為不寫 (預設值測試)。")

結果還是失敗 : 

========================================
💾 Freenove S3 - TF 卡掛載測試 (Freenove 專用版)
========================================
❌ 掛載失敗: 16

【除錯提示】
1. 確保卡片格式是 FAT32。
2. 確保卡片已插緊 (Freenove 的卡槽有時需要推到底)。
3. 如果還是失敗,請嘗試將 width=1 改為不寫 (預設值測試)。

錯誤代碼 16 (OSError: [Errno 16] EBUSY) 表示 "資源忙碌中", Gemini 建議冷開機重試, 但結果還是一樣, TF 卡如果掛載成功會在根目錄下建立一個名為 'sd' 的資料夾, 用下列程式檢查沒有此資料夾 : 

>>> import os  
>>> print(os.listdir('/'))   
['boot.py', 'photo_0.jpg', 'photo_1.jpg', 'photo_2.jpg', 'vibe_photo_23376332.jpg', 'vibe_victory.jpg']

Gemini 認為 TF 卡接在板子的 38, 39, 40 (SDMMC 1-bit), 但此版韌體寫死的 TF 卡腳位可能不是 38/39/40, 當用預設值 (不加參數) 時, 它嘗試去連錯誤的腳位或已被佔用的腳位, 所以才會報錯 EBUSY (資源忙碌), 建議改用通用版 (Generic) 韌體 (檔名裡要有 SPIRAM 或 OCT), 不要使用 Freenove 版韌體. 

MicroPython 學習筆記 : ESP32-S3 CAM 開發板測試 (五)

今天開 QRV 去裕昌做 150K 保養時帶了筆電與 ESP32-S3 CAM 去, 但測試 TF 卡的掛載一直無法成功, Gemini 認為這可能是因為 shariltumin 的 2020 年版韌體雖然拍照沒問題, 但它可能不支援自定義腳位, 鎖死了 SDMMC 的預設腳位 (通常是 GPIO 14/15/2), 而我板子上的印的卻是 GPIO 38/39/40, 建議我改燒新版韌體. 

由於前天找出庫藏 ov2640 更換之前拍照圖像會潰敗的鏡頭模組, 證實問題出在 ov2640 而非韌體, 所以 cnadler86 較新的 2026 版韌體應該是可用的, 或許此版韌體可順利掛載 TF 卡, 本篇旨在測試 TF 卡之前, 先驗證 2026 版韌體可正常拍照. 


首先到  cnadler86 的 GitHub 下載 2026 版韌體 : 




解壓縮後得到映像檔 firmware.bin, 將 ESP32-S3 CAM 的 UART 端口接到 PC USB, 開啟命令提示字元視窗, 用 esptools 先洗掉舊韌體 : 

D:\ESP32-S3-CAM>esptool --chip esp32s3 --port COM4 erase_flash   
esptool.py v4.6.2
Serial port COM4
Connecting.............. (須按 BOOT 鈕)
Chip is ESP32-S3 (revision v0.2)
Features: WiFi, BLE
Crystal is 40MHz
MAC: e0:72:a1:d7:dd:d4
Uploading stub...
Running stub...
Stub running...
Erasing flash (this may take a while)...
Chip erase completed successfully in 4.1s
Hard resetting via RTS pin...

然後燒錄新韌體 : 

D:\ESP32-S3-CAM>esptool --chip esp32s3 --port COM4 write_flash -z 0 firmware.bin    
esptool.py v4.6.2
Serial port COM4
Connecting........................
Chip is ESP32-S3 (revision v0.2)
Features: WiFi, BLE
Crystal is 40MHz
MAC: e0:72:a1:d7:dd:d4
Uploading stub...
Running stub...
Stub running...
Configuring flash size...
Flash will be erased from 0x00000000 to 0x001dbfff...
Compressed 1948208 bytes to 1238683...
Wrote 1948208 bytes (1238683 compressed) at 0x00000000 in 108.2 seconds (effective 144.0 kbit/s)...
Hash of data verified.

Leaving...
Hard resetting via RTS pin...

燒錄完畢拔掉 UART 端口的 Type C 改插 OTG 端口, 按 Thonny 的 "執行/停止-重新啟動" 進入 MicroPython 互動執行環境 : 

MicroPython v1.27.0-dirty on 2026-01-12; Generic ESP32S3 module with Octal-SPIRAM with ESP32S3
Type "help()" for more information.
>>> 

可見確實是 2026 年的新版 MicroPython v1.27 版韌體. 使用之前測試中 Gemini 提供的最終勝利版程式拍照 :

import camera
import time
import os
import gc

gc.collect()

print("========================================")
print("🚀 Vibe Cam - 最終勝利存檔版")
print("========================================")

cam = None
try:
    # 1. 啟動
    cam = camera.Camera()
    cam.init()
    time.sleep(0.5)

    # 2. 設定 (使用剛剛驗證成功的 Keyword 方式)
    try:
        cam.reconfigure(pixel_format=camera.PixelFormat.JPEG, frame_size=camera.FrameSize.VGA)
        print("✅ 格式設定成功 (JPEG/VGA)")
    except:
        pass # 已經設過了或失敗都沒關係,反正數據看起來是對的

    time.sleep(1.5) # 等待緩衝區穩定

    # 3. 拍照
    print("📸 拍攝中...")
    # 為了清空舊緩衝區,建議空拍一張
    try:
        cam.capture()
        time.sleep(0.1)
    except:
        pass
        
    buf = cam.capture()
    print(f"📦 取得數據: {len(buf)} bytes")

    # 4. 【關鍵】不做任何檢查,直接暴力存檔!
    filename = "vibe_victory.jpg"
    with open(filename, "wb") as f:
        f.write(buf)
        
    print(f"💾 已強制儲存至: {filename}")
    print("🎉 請立刻用 Thonny 下載這張照片,它絕對是好的!")
    
    # 5. 釋放
    cam.deinit()

except Exception as e:
    print(f"❌ 錯誤: {e}")

print("========================================")

拍照結果正常 :




純淨拍照版測試程式也改為新版韌體的物件導向寫法 (2026 進化版) :

import camera
import time
import os
import gc

# 1. 記憶體大掃除
gc.collect()
print("========================================")
print("🚀 Vibe Cam - 2026 進化版 (內部存檔)")
print(f"🧠 可用記憶體: {gc.mem_free() / 1024:.2f} KB")
print("========================================")

cam = None  # 先宣告變數,方便後面釋放

try:
    # 2. 初始化相機 (新版寫法:物件導向)
    print("📷 初始化相機物件...")
    cam = camera.Camera()
    cam.init()

    # 【新版關鍵】設定格式與大小
    # 舊版韌體是寫死的,新版建議明確指定,確保是 JPEG 格式
    try:
        # VGA = 640x480, 適合一般測試
        cam.reconfigure(pixel_format=camera.PixelFormat.JPEG, frame_size=camera.FrameSize.VGA)
        print("✅ 格式設定成功 (JPEG/VGA)")
    except Exception as e:
        print(f"⚠️ 設定格式略過 (可能已設定): {e}")

    # 【關鍵技巧】等待光線穩定
    print("🔥 暖身中 (等待 2 秒讓鏡頭適應光線)...")
    time.sleep(2.0)

    # 3. 拍照
    print("📸 拍攝中...")
    
    # 【新版建議】先空拍一張清空緩衝區 (避免拍到上一張殘影)
    try:
        cam.capture()
        time.sleep(0.1)
    except:
        pass

    # 正式拍照
    buf = cam.capture()

    if buf and len(buf) > 0:
        print(f"📦 取得影像: {len(buf)} bytes")
        
        # 4. 智慧存檔 (存入內部 Flash) - 保留您原本優秀的邏輯
        i = 0
        while True:
            # 存到根目錄
            filename = f"photo_{i}.jpg"
            try:
                # 檢查檔案是否存在
                os.stat(filename)
                # 如果沒報錯,代表檔案存在,號碼 +1
                i += 1
            except OSError:
                # 報錯代表找不到檔案,這個名字可以用!
                break
        
        # 寫入檔案
        with open(filename, "wb") as f:
            f.write(buf)
            
        print(f"💾 已儲存至內部空間: {filename}")
        print("🎉 成功!請在 Thonny 左側檔案欄按右鍵 -> Refresh (重新整理) 查看。")

    else:
        print("❌ 拍照失敗 (數據為空)")

    # 5. 釋放資源
    if cam:
        cam.deinit()
        print("✅ 相機已釋放")

except Exception as e:
    print(f"❌ 發生錯誤: {e}")
    # 強制釋放保險機制
    if cam:
        try:
            cam.deinit()
        except:
            pass

print("========================================")

此程式拍照結果會以 photo_x.jpg 依序存檔 : 




另外串流伺服器程式在新版韌體環境也要修改 : 

import network
import socket
import camera
import time
import machine
import gc
import os
import errno

# ==========================================
# 0. 全域變數
# ==========================================
cam = None  # 用來存放相機物件

# ==========================================
# 1. 設定 Wi-Fi
# ==========================================
SSID = "我家的 WiFi 分享器"        
PASSWORD = "連線密碼"   

# ==========================================
# 2. 連線 Wi-Fi
# ==========================================
def connect_wifi():
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    if not wlan.isconnected():
        print(f"🔗 正在連接 Wi-Fi: {SSID}...", end="")
        wlan.connect(SSID, PASSWORD)
        retry = 0
        while not wlan.isconnected():
            time.sleep(1)
            print(".", end="")
            retry += 1
            if retry > 15:
                print("\n❌ Wi-Fi 連線逾時!")
                return None
    ip = wlan.ifconfig()[0]
    print(f"\n✅ Wi-Fi 已連線! IP: http://{ip}")
    return ip

# ==========================================
# 3. 相機初始化 (新版韌體寫法)
# ==========================================
def init_camera():
    global cam
    gc.collect()
    try:
        # 1. 建立相機物件
        cam = camera.Camera()
        cam.init()
        
        # 2. 設定參數 (JPEG, VGA)
        # 注意:新版韌體必須明確指定 PixelFormat.JPEG
        time.sleep(0.5)
        cam.reconfigure(pixel_format=camera.PixelFormat.JPEG, 
                        frame_size=camera.FrameSize.VGA)
        
        print("📷 相機初始化成功 (Mode: cnadler86 2026)")
        return True
    except Exception as e:
        print(f"⚠️ 相機初始化失敗: {e}")
        return False

# ==========================================
# 4. 網頁 HTML (保持不變,因為這段很穩定)
# ==========================================
html = """<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>Vibe Cam 2026</title>
    <style>
        body { font-family: sans-serif; background: #222; color: #fff; text-align: center; padding: 20px; }
        .btn { background: #007bff; color: white; padding: 15px 30px; border: none; border-radius: 5px; font-size: 1.2rem; cursor: pointer; margin: 20px; }
        img { width: 100%; max-width: 640px; border: 2px solid #555; background: #000; min-height: 240px; }
    </style>
</head>
<body>
    <h1>🏠 Vibe Cam (2026 New Firmware)</h1>
    <img id="stream" src="" alt="等待影像...">
    <br>
    <button class="btn" onclick="toggle()">Start / Stop</button>
    <div id="status" style="color:#aaa;">Ready</div>

    <script>
        var active = false;
        var img = document.getElementById("stream");
        var stat = document.getElementById("status");
        
        function toggle() { 
            active = !active; 
            if(active) {
                stat.innerText = "Streaming...";
                load(); 
            } else {
                stat.innerText = "Stopped";
            }
        }
        
        function load() {
            if(!active) return;
            // 加上時間戳記防止快取
            img.src = "/capture?t=" + new Date().getTime();
            
            img.onload = () => { if(active) load(); };
            
            // 如果失敗 (破圖),等 0.5 秒再重試,不要死命重讀
            img.onerror = () => { 
                stat.innerText = "Error (Retrying...)";
                if(active) setTimeout(load, 500); 
            };
        }
    </script>
</body>
</html>
"""

# ==========================================
# 5. 主伺服器 (已更新為新版相機物件)
# ==========================================
def start_server():
    global cam
    
    # 先連網
    ip = connect_wifi()
    if not ip: return

    # 再開相機
    if not init_camera():
        print("❌ 無法啟動伺服器,因為相機失敗")
        return

    # 建立 Socket
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(('', 80))
    s.listen(5)
    s.settimeout(0.5) 
    
    print("✅ 伺服器就緒。請打開瀏覽器,並觀察 Shell 的輸出。")

    try:
        while True:
            try:
                conn, addr = s.accept()
                conn.settimeout(None)
                request = conn.recv(1024).decode()
                
                if "GET /capture" in request:
                    # --- 拍照區 (新版 API) ---
                    # 直接呼叫 cam 物件的方法
                    buf = cam.capture()
                    
                    if buf and len(buf) > 0:
                        conn.send(b'HTTP/1.1 200 OK\r\nContent-Type: image/jpeg\r\n')
                        conn.send(f'Content-Length: {len(buf)}\r\n\r\n'.encode())
                        conn.send(buf)
                    else:
                        print("⚠️ 拍照失敗 (無數據)")
                        conn.send(b'HTTP/1.1 500 Error\r\n\r\n')
                
                elif "GET /nuke" in request:
                    print("☢️ DELETE main.py...")
                    try: os.remove('main.py')
                    except: pass
                    conn.send(b'HTTP/1.1 200 OK\r\n\r\nRebooting...')
                    time.sleep(1)
                    machine.reset()

                else:
                    conn.send(b'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n')
                    conn.send(html.encode())
                
                conn.close()
                gc.collect()

            except OSError as e:
                if e.args[0] == 110 or e.args[0] == 116: continue
                # print(f"Socket Error: {e}") # 太吵可以註解掉

    except KeyboardInterrupt:
        print("\n🛑 停止中...")
        s.close()
        if cam:
            cam.deinit() # 記得釋放物件
            print("相機已釋放")

# 啟動!
start_server()

執行結果如下 :

True

✅ Wi-Fi 已連線! IP: http://192.168.50.111
📷 相機初始化成功 (Mode: cnadler86 2026)
✅ 伺服器就緒。請打開瀏覽器,並觀察 Shell 的輸出。
43
25
26721
43
25
26729
43
25
28113
....

手機連線同一 WiFi 分享器後用瀏覽器連線 http://192.168.50.111 就可以看到連續圖片播放形成的串流視訊效果 :





以上測試證明 cnadler86 的 2026 版韌體拍照沒有問題. 

QRV 15 萬公里保養

上周 Nissan 業務打電話來通知保養時間到了, 預約今天早上 08:30 進廠. 因最近又出現兩次怠速熄火問題, 請師傅查看看原因, 前年換過發電機後此問題就消失了, 黃師傅說可能是進氣口積碳, 先清理再觀察看看. 這次有購買 4 年期 5680 元保養券, 4 年後也快退休了, 準備換 X-Trail ePower. 等車時順便逛了一下新車, ePower 目前是第三代, 油耗可達 17.5 :




我徵詢大師是勸敗或勸忍, 他說等四驅 QASHQAI 款上市再看看 : 



2026年2月13日 星期五

MicroPython 學習筆記 : ESP32-S3 CAM 開發板測試 (四)

今天繼續來測試 ESP32-S3 CAM 開發板, 本篇旨在測試能否透過 ov2640 鏡頭取得圖像之串流 (Streaming). 本系列之前的測試文章參考 :


程式碼使用 Vibe coding 方式叫 Gemini 3 幫我生成, 提示詞如下 : 

我有一塊 ESP32-S3 CAM 開發板, 已經燒錄好 shariltumin 2022 年的 MicroPython 韌體, 網址如下 :

# https://github.com/shariltumin/esp32-cam-micropython-2022/tree/main/firmwares-20230717/ESP32-S3/Freenove-OV2640/WiFi%2BBLE

經測試可正常拍照, 請幫我寫一個 MicroPython 程式讓開發板連上 WiFi 分享器後將鏡頭影像串流放在網頁伺服器上, 讓我可用手機連線網址觀看. 

生成的程式碼如下 :

# main.py 
import network
import socket
import camera
import time
import machine
import gc
import os
import errno

# ==========================================
# 1. 設定 Wi-Fi
# ==========================================
SSID = "我家的 WiFi 分享器"       
PASSWORD = "連線密碼"   

# ==========================================
# 2. 連線 Wi-Fi
# ==========================================
def connect_wifi():
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    if not wlan.isconnected():
        print(f"🔗 正在連接 Wi-Fi: {SSID}...", end="")
        wlan.connect(SSID, PASSWORD)
        retry = 0
        while not wlan.isconnected():
            time.sleep(1)
            print(".", end="")
            retry += 1
            if retry > 15:
                print("\n❌ Wi-Fi 連線逾時!")
                return None
    ip = wlan.ifconfig()[0]
    print(f"\n✅ Wi-Fi 已連線! IP: http://{ip}")
    return ip

# ==========================================
# 3. 相機初始化
# ==========================================
def init_camera():
    gc.collect()
    try:
        camera.init()
        camera.framesize(6) # VGA (640x480)
        camera.quality(12)
        print("📷 相機初始化成功")
    except Exception as e:
        print(f"⚠️ 相機初始化失敗: {e}")

# ==========================================
# 4. 網頁 HTML
# ==========================================
html = """<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>Vibe Cam Stable</title>
    <style>
        body { font-family: sans-serif; background: #222; color: #fff; text-align: center; padding: 20px; }
        .btn { background: #007bff; color: white; padding: 15px 30px; border: none; border-radius: 5px; font-size: 1.2rem; cursor: pointer; margin: 20px; }
        img { width: 100%; max-width: 640px; border: 2px solid #555; background: #000; min-height: 240px; }
    </style>
</head>
<body>
    <h1>🏠 Vibe Cam (Stable)</h1>
    <img id="stream" src="" alt="等待影像...">
    <br>
    <button class="btn" onclick="toggle()">Start / Stop</button>
    <div id="status" style="color:#aaa;">Ready</div>

    <script>
        var active = false;
        var img = document.getElementById("stream");
        var stat = document.getElementById("status");
        
        function toggle() { 
            active = !active; 
            if(active) {
                stat.innerText = "Streaming...";
                load(); 
            } else {
                stat.innerText = "Stopped";
            }
        }
        
        function load() {
            if(!active) return;
            // 加上時間戳記防止快取
            img.src = "/capture?t=" + new Date().getTime();
            
            img.onload = () => { if(active) load(); };
            
            // 如果失敗 (破圖),等 0.5 秒再重試,不要死命重讀
            img.onerror = () => { 
                stat.innerText = "Error (Retrying...)";
                if(active) setTimeout(load, 500); 
            };
        }
    </script>
</body>
</html>
"""

# ==========================================
# 5. 主伺服器 (穩定版)
# ==========================================
def start_server():
    ip = connect_wifi()
    if not ip: return

    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(('', 80))
    s.listen(5)
    s.settimeout(0.5) 
    
    print("✅ 伺服器就緒。請打開瀏覽器,並觀察 Shell 的輸出。")

    try:
        while True:
            try:
                conn, addr = s.accept()
                conn.settimeout(None)
                request = conn.recv(1024).decode()
                
                if "GET /capture" in request:
                    # --- 拍照區 ---
                    buf = camera.capture()
                    
                    if buf and len(buf) > 0:
                        # 【關鍵修正】分兩次傳送:先送標頭,再送圖片
                        # 這樣可以減少記憶體壓力,避免破圖
                        conn.send(b'HTTP/1.1 200 OK\r\nContent-Type: image/jpeg\r\n')
                        conn.send(f'Content-Length: {len(buf)}\r\n\r\n'.encode())
                        conn.send(buf)
                    else:
                        print("⚠️ 拍照失敗 (無數據)")
                        conn.send(b'HTTP/1.1 500 Error\r\n\r\n')
                
                elif "GET /nuke" in request:
                    print("☢️ DELETE main.py...")
                    try: os.remove('main.py')
                    except: pass
                    conn.send(b'HTTP/1.1 200 OK\r\n\r\nRebooting...')
                    time.sleep(1)
                    machine.reset()

                else:
                    conn.send(b'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n')
                    conn.send(html.encode())
                
                conn.close()
                gc.collect()

            except OSError as e:
                if e.args[0] == 110 or e.args[0] == 116: continue
                print(f"Socket Error: {e}")

    except KeyboardInterrupt:
        print("\n🛑 停止中...")
        s.close()
        camera.deinit()

# 執行
init_camera()
start_server()

此程式內建了一個嵌入式網頁伺服器透過網頁來呈現 VGA(640x480) 視訊. 解析度是以 camera.framesize(6) 來設定的, 關於圖像的解析度規格摘要如下表 : 


 解析度  用途  優點  缺點
 VGA
(640x480)
 
(視訊串流王者)
即時監控
網路攝影機 (Webcam)
AI 辨識 (如人臉偵測)
流暢度高
WiFi 良好可達 15~25 FPS
延遲低
畫面幾乎同步,不卡頓。
 放大看細節會糊掉,解析度較低。
SVGA
(800x600)
 
(拍照甜蜜點)
縮時攝影
定時拍照存檔
較高畫質的監控
細節提升
比 VGA 多 50% 像素,文字/人臉更清
傳輸尚可:約 0.5 秒可完成。
幀率下降
串流會掉到 10 FPS 以下。會有明顯殘影或延遲感。
 UXGA
(1600x1200)
 
(極限畫質)
需要看清遠處車牌
或微小細節的靜態照片
發揮鏡頭最大實力 (200 萬畫素全開)。 非常慢
處理需 1~2 秒
噪點多
暗部雜訊明顯
傳輸不穩
易掉封包導致畫面撕裂。


camera.framesize() 的傳入參數代碼說明如下表 : 


代碼 解析度名稱 像素 (WxH) 說明與建議用途
5 QVGA 320x240 速度最快 適合網路訊號極差,或需要超高刷新率 (FPS) 的場合。畫面較小且模糊。
6 VGA 640x480 🎯 預設值 (平衡點) 畫質與傳輸速度的最佳平衡。適合一般的即時監控串流。
7 SVGA 800x600 畫質甜蜜點 畫質明顯比 VGA 清楚,適合縮時攝影或靜態存檔。串流時 FPS 會略降。
8 XGA 1024x768 適合需要看清楚細節的靜態拍照。檔案較大,不建議用於即時串流。
10 UXGA 1600x1200 🔥 硬體極限 200 萬畫素全開。處理速度非常慢 (約 1~2 秒一張),且暗部雜訊較多。


上面的程式並非使用影片串流協定 (MJPEG Stream) 在瀏覽器上呈現視訊, 而是使用一種輕量化的快速連拍 (Rapid Polling) 法, 簡言之, 其原理類似  "翻頁書 (Flipbook) 動畫", ESP32 不會主動一直丟影片給瀏覽器, 而是由網頁裡的 JavaScript 程式中的 load() 函式扮演主動角色, 連續向 ESP32-S3 CAM 要求提供一張照片, ESP32 就拍一張傳過去, 只要速度夠快 (每秒約 5~10 次), 因為視覺暫留, 人眼看起來就像是流暢的影片了. 

為了避免記憶體瞬間爆掉, 此程式分三次與瀏覽器通訊, 先送 HTTP 檔頭, 再送 Content-Length, 最後才送出真正的 JPEG 圖片資料. 為了容錯, 程式中使用了 try...except 包裹住拍照動作, 萬一相機偶爾一次 No Data (沒拍到), 程式不會崩潰, 而是回傳 500 錯誤, 繼續等下一次拍照. 另外還使用 img.onerror 監聽器, 如果這張照片傳輸失敗 (破圖), 瀏覽器不會卡住, 而是休息 0.5 秒後自動重試, 這讓畫面看起來永遠是活著的. 

為了避免 while 迴圈鎖死, 此程式設有逃生門設計, s.settimeout(0.5) 會每 0.5 秒檢查一次是否有按下 Ctrl+C, 如果沒有它就繼續工作; 如果有它就優雅地關閉伺服器. 

將此程式直接貼到開發板的互動視窗, 或存成 main.py 上傳到開發板根目錄下, 先按 Thonny的 "執行/重新啟動", 再按 Ctrl+D 即執行 main.py, 輸出串流伺服器的網址 :

True
📷 相機初始化成功
True

✅ Wi-Fi 已連線! IP: http://192.168.1.112
✅ 伺服器就緒。請打開瀏覽器,並觀察 Shell 的輸出。

這時用手機連線同一 WiFi 分享器, 開啟瀏覽器輸入此網址, 會顯示 "不支援 HTTPS 安全連線" 頁面, 按 "繼續造訪網站" 就會顯示 VibeCam Home 網頁 : 




按 "Start/Stop" 鈕就會出現串流視訊 (底下顯示 Streaming) : 




互動視窗輸出 : 

43
25
12259
43
25
12259
43
.....

其中第一個數字 43 是HTTP 檔頭的第一部分 "HTTP/1.1 200 OK\r\nContent-Type: image/jpeg\r\n" 的字元數 (剛好 43 個, 固定), 用來告訴瀏覽器 "我準備好要傳送一張 JPEG 圖片給你囉". 

第二個數字 25 是 HTTP 檔頭的第二部分 (包含圖片大小訊息, 不固定) "Content-Length: 12259\r\n\r\n", 用來告訴瀏覽器 "這張圖片的大小是 12259 bytes, 請準備接收". 

第三個數字 12259 (會變動) 是當下拍到的那張照片的大小 (byte 數). 




再次按 "Start/Stop" 鈕就會結束串流視訊 (底下顯示 Stopped), 注意此為交替 (toggle) 的按鈕 : 




如果串流只出現框框沒有影像, 需拔除 OTG USB 電源線, 等幾秒鐘電容放電完再上電, 先在 Thonny 按 "執行/重新啟動", 然後按 Ctrl+D 冷啟動就會執行 main.py 了.