2026年2月15日 星期日

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 專版. 

沒有留言 :