2026年3月5日 星期四

React 的好書

最近因為學習 Vibe coding 用法, 發現 AI 通常會使用 React 實作前端網頁, 讓我對此框架產生興趣. 我的前端技術止步於 jQuery, 新的框架也只有淺嘗 Vue 而已並未深入. 雖然在 AI 時代其實已無必要去看底層代碼, 學習 React 純粹只是出於好奇 (為何 jQuery 不能滿足呢) 想多了解一下而已. 

我從圖書館借到下列 React 書籍, 打算抽空來學一點基礎知識, 然後做些能彰顯 React 價值的經典測試就算有學過 React 了 :


2026年3月4日 星期三

申請行動郵局

由於爸從 3/2 起開始參加社區據點活動, 每個月午餐月費 800 元, 要轉帳到社區協會的農會帳戶, 而居家服務則改為晚餐, 帳戶是郵局, 這樣會超過我台銀轉帳免費次數, 所以今天去郵局臨櫃辦理行動郵局, 居家服務的月費就從行動郵局轉. 

帶郵局存摺印章與身分證臨櫃辦理即可, 參考 : 


辦好後會拿到一張密碼單, 須在一個月內更改帳號, 登入密碼與交易密碼 (手機下載行動郵局 App 後先用密碼單之初始帳密登入後修改). 

2026年3月2日 星期一

蝦皮買日式實木壁燈 E27 燈座

上周在高雄家的中央走道安裝了一個定時亮滅 (使用小圓 Tuya 控制器 17:10 ON, 20:10 OFF) 的壁燈, 這樣傍晚回來屋子正中央就是亮亮的 (此財位也). 但陽春的燈座不甚美觀, 於是上蝦皮買了一個日式實木壁燈燈座, 需要在牆壁鑽一個孔安裝 :






明華智取店取付免運 450 元. 

2026年3月1日 星期日

2026 年第 9 周記事

這周是過年後上班的一周, 月底有同事提早退休, 雖然羨慕他們能提早自在逍遙, 但如果早退又沒規劃做甚麼, 那還不如繼續幹活. 年前我盤點了一下退休後的財務, 發現其實我已符合早退資格, 況且家有田產, 隨時要退都可以. 周六參加工專畢業 40 周年同學會, 談話主題也是退休, 但早已退休的祥雲兄跟我說別早退, 他自己退休後又出來找事做. 射手座無法忍受無所事事. 

因三月中旬要跟水某與菁菁遊日本, 回來的下一周就要掃墓, 所以我今天去整理來台祖墓園, 下周六則是整理自家的家祠. 228 一整天下毛毛雨 (上天的哀悼?), 所幸今日晴空萬里才得以上工, 上周末已清除墓園內雜草, 今日主打周圍蔓生的鬼針草與姑婆芋等, 兩顆打草機電池都用光剛好大致完工, 剩後面一點點下周六總整理時一併清除. 


Before


After


意外發現姑婆芋的果實是鮮豔的紅色 :




最近學研進度緩慢, 因為前陣子在玩 ESP32-S3 CAM, 所以 Gemini CLI 進度掛零, 但本周已看完林彥文老師的 Vibe coding 課程, 最近則是在學習 Bubble.io, 但很快就會回到 Vibe coding, 這些都是為了退休後有事情做準備. 

2026年2月28日 星期六

40 周年同學會

昨天 (2/27) 中午到六合路慢活的普普參加 40 周年同學會, 感謝大帥費心籌辦才能促成, 雖然只有 15 位同學到場, 但想到原來離開校園已 40 年了, 大家都來到初老年紀, 能見上一面暢聊一下午, 這種機會並不常有, 覺得這真是彌足珍貴的一次聚會啊!

同學中已有人提早離開職場, 話題自然也離不開退休, 是否要早退, 每個人面臨的主客觀因素都不同, 但我與俊宏會後都有共同的領悟 : 應該在退休前換車, 因為能開車的時間似乎也不多了. 等峰大師勸敗的 e-Power 第三代出來就買吧!

購買 LG 電視遙控器 (42LM6200)

鄉下老家客廳的 LG 電視遙控器失靈, 雖然也可能是電視本身老舊, 但還是先買新的遙控器來測試看看, 反正這台與高雄家的 LG 電視共用同款遙控器 :





明華智取店取付免運 199 元. 

前陣子在露天買全新 Logitech C270 賣家寄來拆機中古 C170, 說要給我退換至今超過兩個月推拖拉打哈哈, 此賣家有加入支付連且可開統編與發票仍被呼弄上當 (三月最後通牒若還不解決我再來整理讓賣家驚艷一下), 現在幾乎都改在蝦皮買. 露天現在真的是詐騙天堂無誤 (但過去十幾年在露天交易從未被騙, 一旦發生才發現平台的處理機制很爛). 但不管哪個網購平台, 千萬不要選超商取付, 萬一上當無法攔截金流, 蝦皮務必選智取店, 不滿意可退貨, 立即叫平台止付, 這是我這一年來頻頻在露天網購被騙後轉往蝦皮交易的一點心得. 

2026年2月27日 星期五

Bubble.io 學習筆記 : 註冊免費帳號 (Free Plan)

Bubble.io 是一個整合前端設計, 後端邏輯, 與資料庫管理於一體的全功能無程式碼 SaaS 開發平台, 亦提供雲端託管服務, 可直接佈署平台上開發的 Web/Mobile App, 非常適合非程式設計背景的開發者將創意發想快速實現. 本系列之前的文章參考 :


Bubble.io 提供 Starter, Growth, Team, 與 Enterprise 四級付費帳戶方案, 亦有免費帳戶供使用者熟悉 Bubble 平台用法, 各方案功能與限制如下表 : 


 項目  Free  Starter  Growth  Team  Enterprise
 月費 (約略)  $0  $59/月  $209/月  $549/月  客製報價
 是否可 Live 上線  ❌  ✔️  ✔️  ✔️  ✔️
 自訂網域  ❌  ✔️  ✔️  ✔️  ✔️
 編輯者數量  1  1  2  5  客製
 Workload Units / 月  50K  175K  250K  500K  客製
 伺服器日誌保留時間  6 小時  2 天  14 天  20 天  客製
 版本控制 (Branch)  ❌  基本  10 Branches  25 Branches  完整支援
 Recurring Workflows  ❌  ✔️  ✔️  ✔️  ✔️
 2FA 安全驗證  ❌  ❌  ✔️  ✔️  ✔️


對於初學者而言, 可先註冊免費帳戶來了解 Bubble 的基本用法, 如果 App 需要上線再升級為 Starter 以上帳戶. 以下情況就一定要升級付費方案 : 
  • 想要正式對外上線 (Live App)
  • 需要自訂網域
  • 有實際用戶的商業用途
  • Workload (WU) 已不夠用
  • 需要多人協作或版本控管
本篇旨在紀錄註冊免費帳戶過程. 首先到官網按右上方的 Login 鈕 :





按底下 Sign Up 鈕 :




可以選擇以 Google 帳號 (最快) 或 Email 註冊 (密碼需含大小寫字母數字與特殊符號, 長度至少 8 個字元), 按 Start Building 會寄確認信到 Email 信箱 : 





註冊程序會直接進入 App 建置頁面, 為了展示可點選 "Internal tool", 會在輸入框自動帶入預設的員工資料管理 Web app 提示詞, 此 App 包含大部分的 Bubble 主場功能, 例如 CRUD, Workflow (條件, 狀態, 流程), 權限控制, 以及表單搜尋與篩選等 : 




按藍色的右鍵頭即開始自動建立此 Web app :




這時會顯示詢問是否要使用高級功能的 14 天試用版, 先不要啟動試用版, 按最底下的 Start with basic feature 連結即可 :


 

詢問如何得知 Bubble.io, 隨便按一個便會將 App 提示詞交給 AI : 





顯示 AI 規劃此 App 之藍圖, 按右上角的 Generate app 鈕開始生成 App : 





約幾十秒即完成 App 建置, 並彈出的意見調查表 :




填好意見調查表後按如下頁面中的 "preview my app" 就會顯示此 Web app  :





網址如下 : 


以上只是利用提示詞透過 AI 快速生成一個 Web app 的展示而已, 也可以手工操作編輯器來調整 App 的 UI 與工作流程, 後續將以實際範例進行測試. 

2026年2月26日 星期四

無程式碼 SaaS 開發平台 Bubble.io

最近在上課時得知一個很棒的無程式碼 SaaS 開發平台 Bubble.io :


Bubble.io 為 Emmanuel Straschnov (法國工程 & 商業背景, 哈佛 MBA) 與 Josh Haas (Harvard 畢業的程式設計師) 於 2012 年在紐約共同創立, 是一個將前端設計, 後端邏輯, 與資料庫管理整合在一起的全功能 no-code 開發平台. 他們當時觀察到一個問題 : "想創業的人遠多於會寫程式的人", 當時 Josh Haas 在紐約接觸了大量創業者, 發現許多創意發想其實不需要高度創新的技術, 然而 80% 的 Web App 需要的登入/註冊, CRUD 操作, 付款金流, Workflow, 與權限控管等功能卻每次都要由工程師從頭寫起, 於是促成了 Bubble.io 的成立, 其宗旨是要讓軟體開發不再專屬於工程師, 使不具備傳統程式開發背景的人 (就是小白啦) 也能透過視覺化的介面建立功能複雜的網頁應用程式 (Web Apps). 以下是 AI 情蒐整理. 


1. 主要功能 :
  • 視覺化拖放編輯 (Visual Editor) :
    使用者可以自由地放置按鈕, 文字, 輸入框等元件, 並可透過拖拉來調整排版.
  • 工作流邏輯 (Workflows) :
    這是 Bubble 的靈魂, 可以免程式碼設定 "當 A 發生時執行 B" 的邏輯, 例如, 當使用者點擊 "註冊" 按鈕時將資料寫入資料庫並發送驗證郵件等.
  • 內建資料庫 (Built-in Database) :
    使用者無需另外設定 SQL 或其他資料庫, 可直接在 Bubble 內部定義資料類型 (Data Types)與欄位. 
  • 強大的 API 整合 :
    雖然是 No-code, 但它支持 API Connector, 可連接任何第三方服務 (如 OpenAI, Stripe 支付, Google Maps 等). 
  • 雲端託管 : 開發完後可直接在 Bubble 的伺服器上部署上線, 不需要自己處理伺服器維護或環境設定. 

2. 優點 :
  • 開發速度極快 :
    與傳統撰寫程式碼相比, 開發時間可縮短數倍.
  • 門檻很低 :
    創業者或 PM 能夠以極低成本製作出 MVP (最小可行性產品) 來驗證市場或展示功能.
  • 靈活性高 :
    幾乎可以自定義任何視覺細節與操作邏輯. 

3. 缺點與限制 :
  • 學習曲線 :
    雖然不用寫代碼, 但因為功能極其強大, 理解其資料架構與邏輯流程仍需要一定的學習時間才能操作自如. 
  • 平台依賴性 :
    運行在 Bubble 上的應用程式無法取得原始碼移植到其他伺服器 (但資料可匯出成 CSV / JSON 下載). 
  • 效能限制 :
    對於極大規模或運算極其複雜的應用, 其性能表現可能不如原生開發. 
總之, Bubble 非常適合拿來快速驗證產品想法 (快速做出 MVP), 實作 AI / API 整合型產品, 或者當 PM 的 "可運作規格書". 不過, 在 Bubble.io 製作的 MVP 我們無法取得傳統意義上的 "原始碼", 即使是付費帳戶也不行, 因為 Bubble 是封閉的 no‑code 平台 (SaaS), 不是 code generator, 我們在 Bubble 裡做的不是寫程式碼而是在描述一個 App 的結構與行為模型, 因此它沒有一個可讀可維護的程式碼專案可以讓我們匯出, 也不能把 Bubble App 轉成一個可在外部自行部署的程式碼專案, 所製作的 App 也只能在 Bubble 的 runtime 上運行, 只有資料可匯出而已. 

Bubble.io 官網提供豐富使用教學 (英文), 參考 :


尤其是 30 分鐘的快速上手, 從註冊帳號到建立 App 手把手的互動導覽教學 :


YT 上也有豐富的教學影片, 例如 : 


下面是官方的教學影片列表 (英文) :


其它線上教材 : 


2026年2月23日 星期一

2026 年第 8 周記事

長達 8 天的春節假期終於過去, 姊姊與二哥初四晚上就回北部了, 原先與老同學大帥約初四早上見面, 也因為行程變動改為初五 (周六) 早上. 想說午前要返回鄉下, 但久未見面一聊就中午了, 只好留下來吃飯, 午後繼續聊到過了三點才啟程回家, 大帥說他以後要出家, 持續清理收藏, 我獲贈一個京劇五路財神壁飾, 嗯這個好, 畢竟我是俗人一個, 韓信是兵多多益善, 我是錢多多益善哈哈. 由於大帥的衛武營家我只去過一次, 這次用我的備用手機 iPhone SE 開導航, 但停好機車卻忘了取下手機, 直到要回家時下樓才想到, 急忙跑到機車格查看, 好佳在, 我的 iPhone SE 還在手機架上. 

過年前終於把舊豬舍廁所的馬桶漏水問題解決了, 原因出在水箱與馬桶間的 L 型水管接縫不密, 原先的填縫材料經過多年後已腐壞, 我先用電火膠布包覆螺紋鎖緊, 外面再纏繞多層電火布, 最後打上矽膠等待一天乾燥後即可. 過年後初六馬上來處理餐廳後面浴廁馬桶漏水問題, 這個比較麻煩, 原因是外面的汙水管與馬桶出水口有約兩公分落差, 當時水電工使用橡皮軟管銜接, 時間一久橡皮腐蝕造成漏水, 使得浴廁有異味. 我去五金行沒找到這種橡皮軟管, 只好用 3.5 吋x 3 吋喇叭口 PVC 硬管切割後銜接, 外面再上下各套一片切開的 3.5 吋硬管用螺絲鎖緊, 最後纏繞電火布並打上矽膠. 現在要找個水電工都不容易, 只好自己土炮 (工程尚未驗收). 




另外一個是自來水上水管漏水問題, 材料都買好了, 因時間不夠昨日無法施工, 要 228 連假才有時間, 到時連同會滴水的廚房水龍頭一起換掉 :



鄉下老家的兩個 WiFi 網路速度測試

年初二早上發現 ASUS AX3000 WiFi 分享器 (WiFi 6) 無法連線, 原因是除夕整理桌面誤按了延長線開關斷電, 重新啟動後順便測試一下新 (AX3000) 舊 (Edimax, WiFi 5) 這兩個 WiFi 熱點的網速, 首先到中華電信下載測速軟體 (免安裝) :



1. 直連網速 :

書房桌機直連分享器, 測得網速確實為光世代 300M :




2. WiFi 6 網速 :

使用 LG Gram 筆電於臥室連線 ASUS AX3000 測試兩次 (距分享器約 10 米) :





3. WiFi 5 網速 :

以 LG Gram 筆電於臥室連線 Edimax 分享器測試兩次 (距分享器約 6 米) :





WiFi 5 網速算是勉強可以接受啦, 優點是信號可以傳送的距離較遠. 小米攝影機都是連線這台才才能綁定成功. 


4. 手機熱點網速 :

以 LG Gram 筆電於臥室連線手機熱點 (4G+) 測試兩次 (距分享器約 1 米) :





中華電信 5G 上路這麼久了, 在我們老家這邊網速實在太 Low 啦! 

2026年2月18日 星期三

2026 年第 7 周記事

不知不覺年假已過了一半, 今日已是周三年初二了, 差點忘記要寫周記了. 二哥小年夜近 12 點才到高雄, 我那天忙大掃除與貼紅紙到九點才出去高雄, 順便給小咪與萬萬補過年期間的糧食, 然後出發到高鐵載二哥, 回到鄉下已經一點多了. 

除夕早上先去榮發舅媽那邊拿牲禮, 回家貼好對聯就等垃圾車, 丟完年前垃圾才去鎮上市集買菜, 回到家菁菁說過年期間讓我休息由她掌廚, 煮了一桌減脂健康餐 : 




吃過午飯便開始準備祭祖事宜, 其實一點多就陸續聽到鞭炮聲, 我們家算是村裡最晚放鞭炮的了. 收完祭品水某說還要去全聯買東西, 我也順路去阿泉伯與松尾伯母家送禮盒, 伯母每年過年都買了豬肉送來, 今年我拿去鎮上豬肉舖絞了準備初二包水餃用. 除夕夜照例是吃火鍋, 這次食材的量控制得剛剛好都吃完, 往年都剩下太多, 年初一中午才消耗完. 

初一照例是去獅形頂與附近三個土地公廟拜拜, 途中接到小舅媽電話, 說有浸魷魚要叫小舅拿兩尾過來, 做為晚餐的一道菜. 但爸說他忘記怎麼做魷魚醬了, 我記得成分是糖, 味磳, 薑末, 與醬油, 試調結果味道試對了, 但味磳加太少不夠濃稠. 另外還做了做了三色蛋, 菁菁則做了薑汁牛肉. 




初二無行程 (多年來回娘家都是初三到岳父鄉下的農舍), 早上睡到自然醒, 照例下午包水餃. 




我煮好晚餐的湯後, 邀爸一起去阿運伯母家坐坐, 她常打電話來關心爸, 也叫我有空去她家坐聊. 傍晚要回家時, 在門口遇到小添伯 (姑婆的孫子), 聊起她女兒美珍曾在高雄遇到我, 我卻一點印象也沒有, 於是拉我到他家, 但見到美珍我還是沒印象, 她說是在公司後面那家小七巧遇, 短暫聊了一下, 提到的地點都對, 我卻想不起來, 看來我是貴人了. 

2026年2月16日 星期一

除夕祭祖用品備忘錄

年節祭拜用品備忘錄獨缺過年的, 今天貼好對聯整理一下備品清單 : 
  • 向榮發舅媽訂牲禮 (一周前)
  • 魚罐頭 x 1, 糖果 x 2, 泡麵 x 1, 素麵 x 1, 素三牲 (非善非惡較便宜 100 元)
  • 香 : 兩大包, 金 : 五福 + 金銀各一, 鞭炮 x 1, 花炮 x 1, 防風燭 x 2  對
  • 米酒 x 2, 紅紙 (貼年糕用, 嬸婆的柑仔店有)
  • 紅紙 : 五福臨門 x 70, 金玉滿堂 (祖堂) x 5, 天官賜福大張 x 9 (龍神 3, 天神 3, 灶君 3)
  • 年糕 x 5 (祖堂 2, 天神 1, 龍神 1, 灶君 1, 要貼紅紙)
  • 水果 : 蘋果(12), 橘子 x 12, 香蕉 x 12, 奇異果 x 12, 紅龍果 x 3
  • 大吉大利綜合包 x 5 (土地公 x 3, 五穀爺 x 1, 天公生 x 1 )
  • 除夕或前一天早上貼對聯
對聯與五福臨門最好是在早上貼, 因下午風大容易被吹落. 注意對聯左右有分 (面對祖堂大門分左右),  原則是末字上 (右) 仄下 (左) 平 , 上聯在右末字為三四聲 (仄), 下聯在左末字為一二聲 (平). 例如 "積善之家慶有餘" 末字餘為二聲 (平) 為下聯要貼左邊; "向陽門第春先到" 末字到為四聲 (仄) 為上聯要貼右邊. 

除夕午飯後先泡一壺茶, 擺好祭品先斟茶再點香祭祖. 祭文模板如下 :

拜請 ○○ 堂 ○ 氏歷代高曾祖以降, 公太, 太太, 阿公阿嬤, 伯公伯婆, 叔公叔婆, 媽媽, 弟弟, 阿蘭等堂上各位, 今分日嘿舊曆十二月二十九 (或三十) 日, 屋家有備辦牲禮果品還有米酒, 阿太吃齋嘛有準備齋菜, 子孫全部有到齊來上香, 敬請堂上各位來享用暢飲, 拜. 

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