2026年9月13日 星期日

聲聲畫家鄉:用台語說出家鄉料理記憶

聲聲畫家鄉:用台語說出家鄉料理記憶

長輩講台語,AI整理食譜、畫出家鄉味

115年臺南澎湖農村綠色照顧四面向行動方案

一碗料理,不只是食材與步驟,也保存著一個家庭、一段漁村生活及長輩的人生記憶。本活動讓長輩不必學打字,只要用最熟悉的台語慢慢說,AI便能協助整理料理故事、1人份食材與做法,最後生成一張有食材圖片、步驟圖解及長輩捧著料理的A4食譜圖卡。

一、活動特色:讓科技配合長輩

活動的重點不是教長輩操作複雜的生成式AI,而是讓手機成為一位會聆聽的料理訪問者。長輩只要對著手機說台語,AI一次問一個簡單問題;若內容聽不清楚,AI會再詢問,不自行增加人物、食材、數量或做法。

1看圖喚起記憶
2台語慢慢講
3確認食譜內容
4生成A4圖卡

二、活動前的準備

  • 可以使用語音交談及生成圖片的手機或平板。
  • 準備澎湖絲瓜、小管、海菜、南瓜、花生、鮮魚、牡蠣及麵線等食材照片。
  • 每一至二位長輩安排一位工作人員或學生陪伴。
  • 選擇較安靜的座位,先測試麥克風、喇叭與網路。
  • 若要讓圖卡呈現長輩本人,應先取得同意,再拍攝或上傳清楚照片。

三、台語訪談的八個問題

工作人員開啟ChatGPT語音模式後,AI必須全程使用簡單、自然的台語,而且一次只問一題。

順序AI可以這樣問希望取得的資料
1阿伯/阿姆,你今仔日想欲介紹啥物料理?料理名稱
2這項料理,是佇佗位煮?啥物時陣會食?地點與時節
3彼當時有啥物人佮你做伙煮?共同記憶中的人物
4欲準備啥物食材?食材名稱
5逐項食材大約愛偌濟?數量與份量
6頭一步按怎做?紲落來按怎做?料理順序
7大約愛煮偌久?烹調時間
8你上懷念的是啥物味、啥物人,抑是啥物情景?料理故事

若長輩只說「半條絲瓜」或「一碗麵」,AI可以協助換算,但一定要再問:「我共你換算做120克,按呢敢有著?」得到確認後才能記錄。

四、第一段指令:讓AI用台語訪問

我是參加「115年臺南澎湖農村綠色照顧四面向行動方案」的長輩,想用台語講一項記憶中的家鄉料理。 請你擔任親切的澎湖料理訪問者,全程用簡單、自然、長輩容易聽懂的台語和我交談。請講慢一點,一次只能問一個問題,等我回答後才可以繼續問下一題。 請依序問我: 1. 想介紹啥物料理? 2. 這項料理佇佗位煮、啥物時陣食? 3. 當時有啥物人做伙煮? 4. 使用啥物在地食材? 5. 每一項食材大約用偌濟? 6. 頭一步按怎做?後面的步驟按怎做? 7. 大約愛煮偌久? 8. 上懷念的是啥物味道、人物抑是情景? 若食材名稱、數量或做法聽無清楚,請繼續用台語問我,毋通家己增加資料。 訪問完成後,請整理成:料理名稱、100至150字料理記憶、1人份食材與精確數量、4至5個料理步驟、烹調時間,以及長輩最懷念的一句話。 請先用台語讀予我聽,最後問我:「按呢整理敢有著?」等我確認正確後,才詢問我是否生成A4直式食譜圖卡。

五、先確認,才可以畫圖

AI整理完成後,長輩或陪伴者應逐項檢查。尤其是料理名稱、食材、份量、烹調步驟及人物,不正確就用台語告訴AI修改。只有當長輩回答「有著」或「正確」,才能進入圖卡生成。

建議的標準食譜內容:
  1. 料理名稱。
  2. 100至150字料理記憶。
  3. 1人份食材及精確數量。
  4. 4至5個簡短料理步驟。
  5. 烹調時間。
  6. 長輩最懷念的一句話。

六、第二段指令:生成A4食譜圖卡

請將剛才經過長輩確認的料理內容,製作成一張A4直式繁體中文食譜圖卡。 圖卡要求: 1. 最上方清楚呈現料理名稱。 2. 左側列出「1人份食材」,每項食材都要有圖片及精確數量。 3. 右側安排一位親切的家鄉長輩,雙手捧著完成的料理;若已提供並同意使用長輩照片,請參考其外貌。 4. 下方呈現4至5個料理步驟,每一步都要有獨立操作圖片、步驟編號及簡短文字。 5. 呈現澎湖海島、漁村、農村及在地飲食特色。 6. 文字使用清楚、正確的繁體中文,不得出現簡體字、亂碼或英文。 7. 不得增加長輩沒有說過的食材、人物、地點或料理方法。 8. 最底部完整加入:「115年臺南澎湖農村綠色照顧四面向行動方案」。 9. 字體要大、對比清楚,適合長輩閱讀。 10. 採用溫暖、自然、健康的食譜海報風格。

七、工作人員的陪伴任務

  • 協助,不代答:讓長輩自己說出記憶,學生只協助按麥克風及確認畫面。
  • 一次一問:長輩回答完畢後,再進入下一題,不急著追問。
  • 尊重原話:保留長輩慣用的料理名稱與生活語彙。
  • 確認份量:先記錄「一把、半碗、半條」等生活量詞,再協助換算並徵得同意。
  • 檢查文字:圖片中文字可能產生錯字,輸出或展示前必須校對。
  • 尊重肖像:未取得同意,不使用長輩本人照片生成圖像。

八、從一張食譜卡看見四面向綠色照顧

四面向活動中的實踐
綠場域從澎湖社區、漁村與家庭廚房找回地方記憶。
綠飲食認識在地、當季食材,整理適合1人份的健康料理。
綠療育透過回想、口述與圖像創作,促進表達及成就感。
綠陪伴由青年或志工陪伴長輩使用語音AI,形成代間共學。
重要提醒:AI是記錄與創作工具,不是營養師或醫師。若長輩有糖尿病、腎臟病、高血壓、吞嚥困難或食物過敏,食材與份量仍應由營養或醫療專業人員確認。

九、結語

當長輩說出「阮細漢就是按呢煮」,被保存的不只是一道菜,更是澎湖的海味、家庭的關係與地方生活智慧。透過台語訪談、AI整理及食譜圖卡,長輩從被服務者成為知識提供者;青年也能在陪伴過程中重新認識地方,讓一代人的料理記憶成為下一代看得見、學得會、傳得下去的文化資產。

綠照趣旅行:36格智慧互動地圖與六個活動範例

115年臺南分署臺南澎湖地區農村區域亮點計畫

綠照趣旅行:臺南・澎湖36格智慧互動地圖

跟著小車遊庄頭,認識在地好生活!

一張地圖、一台小車,可以如何串起長輩的生活經驗與孩子的好奇心?「綠照趣旅行」把臺南與澎湖的景點、料理、工藝及水果圖卡放進6 × 6的方格地圖,讓參與者從選擇目的地開始,一起想路線、操作小車,再把到站後看到的圖卡,變成彼此交流的話題。

本文整理海報中的四種探索功能、基本操作方法,以及六個可在社區活動中使用的任務範例,提供綠照員、志工、教師與學生帶領者參考。

綠照趣旅行海報:臺南澎湖36格智慧互動地圖、四種探索與四步驟操作
圖1 綠照趣旅行功能與操作海報。地圖插畫用於活動示意,實際格位請以現場6 × 6教具為準。

一、讓地方故事成為學習的起點

地圖共有36格,圖卡交錯呈現地方特色與水果圖像。參與者可以選一個想認識的地方,或一種熟悉的食物,將它設定為小車的目的地。每一次移動,都包含「觀察、選擇、規劃、執行與調整」的過程。

長輩能夠提供料理、農作與生活記憶,孩子或學生可以一起辨識方向、記錄指令。帶領者讓雙方輪流決定與操作,便能把教具活動延伸為代間交流。

活動定位:本活動以圖卡探索、按鍵操作與現場分享為主。「智慧互動」在此指透過小車的指令操作練習規劃與回饋;圖卡內容由參與者及帶領者解說。本文未將自動辨識圖卡或自動語音導覽列為設備功能。

二、一張地圖,四種探索

面向探索內容活動中的做法
綠場域認識庄頭景點看港口、海岸與社區圖卡,分享地方景色和生活經驗。
綠飲食探索在地食材從水果與料理圖卡出發,聊食材、做法與家庭味道。
綠療育欣賞地方工藝觀察工藝圖卡的材料、形狀與顏色,交流手作或看戲記憶。
綠陪伴合作操作小車輪流規劃路線、輸入指令、觀察結果,並互相協助。

同一個任務可以同時涵蓋多個面向。例如,操作小車前往料理圖卡,既能探索綠飲食,也能透過合作體驗綠陪伴。

三、四步驟,出發趣!

  1. 選目的地:挑一張想探索的圖卡,說出「我想去這裡」及選擇原因。
  2. 規劃路線:將小車放在起點格中央,確認車頭朝向,再用手指或方向卡排出路線。
  3. 輸入指令:依設備的操作方式輸入移動與轉向指令,確認後啟動小車。
  4. 到站分享:觀察目的地圖卡,說一段記憶、介紹一種食材,或提出一個問題。

開始前,先試走一次

將地圖平放在穩固、平整且留有移動空間的位置。先測試小車前進一次的距離是否對應一格,轉向是否為原地90度,並確認如何清除上一輪指令。若設備的步距與格子大小不一致,應先調整教具配置或依該設備的方式操作。

路線中的「前進」以小車車頭方向為準。小車右轉後,下一次前進的方向也會跟著改變;因此,每次重新出發都要確認起點與車頭。

以下格號依本次36格地圖:由左至右、由上至下編為1至36號。圖卡若重新排列,請同步調整任務。海報插畫與AI重製圖卡中的細字,宜以原始圖卡核對後再用於地方介紹。

四、六個活動範例:從單站體驗到三站旅行

第一次體驗可先選一個短程任務。熟悉操作後,再增加站點;不需要一次完成全部六個活動。

範例01|綠飲食 × 綠陪伴

小車買水果

任務:從7號草莓出發,前往14號橘子。

將小車放在7號格中央,車頭朝向8號。請參與者用手指走一次「7 → 8 → 14」,再討論需要哪些指令。

若前進一次為一格、右轉為原地90度:
前進 → 右轉 → 前進 → 執行

小車先到8號,原地右轉後朝向14號,再前進一格。請留意:這條路線中的右轉只改變朝向,不會直接移動到下一格。

到站提問:「您最喜歡吃什麼水果?」「以前買水果,會去哪裡買?」

台語帶領語:「咱欲去買橘仔,先想看覓,小車愛按怎行?」

進階玩法:讓參與者另選一張水果圖卡作為下一站,自行決定起點與車頭,再規劃路線。

範例02|綠場域 × 綠陪伴

跟著小車看海趣

任務:前往3號重光漁港,看圖找出與海邊生活有關的景物。

帶領者先選定附近的起點,讓參與者規劃到3號的路線。到站後,大家一起觀察圖卡中的漁船、港口與海面,說出自己看到的細節。

到站提問:「您曾經到漁港做什麼?」「您記得海邊有哪些聲音?」

台語帶領語:「您有佇海邊看過日出無?」

進階玩法:再選27號菓葉社區看日出作為第二站。路程較長時可以分段操作,每段完成後重新確認車頭方向,再繼續旅行。

範例03|綠飲食 × 綠陪伴

古早味尋寶

任務:從19號古早味粿仔與36號紫菜炒米粉中,選一道想介紹的料理。

先請長輩選目的地,再由同伴一起排路線。小車到站後,請長輩介紹自己熟悉的食材或做法;孩子或學生可以記下幾個關鍵詞。

到站提問:「這道料理需要哪些食材?」「以前家裡是誰做的?」「通常在什麼時候吃?」

台語帶領語:「這道料理,以前攏啥人煮?您上懷念啥物滋味?」

進階玩法:把分享整理成「料理名稱、食材、記憶一句話」的小卡。不同家庭的做法可以並列記錄,不必要求只有一種答案。

範例04|綠療育 × 綠陪伴

庄頭工藝小旅行

任務:選17號工藝圖卡或22號掌中聲影偶作為目的地。

參與者到站後,先從圖像辨認作品的形狀、色彩與可能使用的材料,再分享自己曾看過或做過的手工藝。材料若無法從圖片確認,可留作之後查證的問題。

到站提問:「這件作品讓您想到什麼?」「您以前做過哪些手工?」「看布袋戲時,最喜歡哪個角色?」

台語帶領語:「您以前有看過布袋戲無?上佮意啥物角色?」

進階玩法:準備可觸摸的工藝材料樣本,讓參與者描述粗細、軟硬與觸感,再與圖卡中的作品比較。

範例05|綠陪伴

聲聲畫家鄉

任務:前往26號聲聲畫家鄉,到站後分享一段家鄉記憶。

請參與者從熟悉的人物、地方、料理或農村生活中選一個主題。帶領者一次只問一個問題,等對方說完再接著問,讓故事按照分享者的步調展開。

依序提問:「您想講哪一段記憶?」接著才問「發生在哪裡?」「當時有誰?」「最懷念什麼?」

台語帶領語:「細漢時陣,啥物代誌予您上懷念?」

進階玩法:若另備手機,可在取得分享者同意後錄音,或協助整理成短文;先讓本人確認內容,再製作故事圖卡。這是活動延伸工具,不是小車本身的功能。

範例06|四面向整合

我的庄頭一日遊

任務:每組選「一個景點+一道料理+一項工藝」,安排三站旅行。

例如選擇3號重光漁港、19號古早味粿仔、22號掌中聲影偶,依小組決定的順序出發。這是教具中的跨地點探索組合,不代表真實世界的一日交通行程。

小組可分為導航員、操作員與分享員:導航員說明路線,操作員輸入指令,分享員在到站後介紹圖卡。每到一站交換角色,讓每個人都有選擇與操作的機會。

到站提問:「為什麼選這三站?」「如果朋友來玩,您會如何介紹?」

台語帶領語:「若朋友來阮庄頭,您欲帶伊去佗位?」

進階玩法:增加「經過一張水果圖卡」或「找另一條可行路線」等條件。先確認路線走得通,再比較不同規劃,不以速度決定表現。

五、社區活動怎麼帶?40分鐘示範流程

以下為可調整的教學安排,並非固定執行時程。可依參與者人數、小車數量與熟悉程度延長分享時間。

時間活動帶領重點
5分鐘認識地圖與選圖卡讓參與者說出一張熟悉或感興趣的圖卡。
5分鐘示範前進與轉向確認步距、車頭方向及清除指令方式。
10分鐘水果短程任務先用手指走,再輸入指令;完成後交換操作。
15分鐘自選主題探索從其他範例選一個,結合到站分享。
5分鐘回顧與交流說出今天發現的一件事,及下一次想去的圖卡。

走錯格,也是一起思考的機會

若小車沒有到達目的地,先請大家指出「原本預計到哪裡」與「實際到哪裡」,再檢查起點、車頭、指令及步距。一次調整一個地方,重新嘗試。帶領者可以問:「哪一步跟我們想的不一樣?」

初學者可以先用兩三個指令完成短路線;熟悉後再增加轉彎或站點。讓同伴提供口頭提示,並保留操作者自己按鍵與修正的機會。

六、從到達目的地,到聽見彼此的故事

活動觀察可以著重於參與的過程:是否能自己選圖卡、說出下一步方向、輪流操作、遇到偏差後願意再試,以及是否有機會分享或聆聽故事。這些可作為教學紀錄,不應單憑一次遊戲判斷健康或認知狀況。

  • 今天我選了:哪一張圖卡?為什麼?
  • 今天我試了:哪一個操作或路線?如何調整?
  • 今天我聽見:同伴分享了什麼地方記憶?
  • 下次我想:再探索哪個目的地?

「綠照趣旅行」讓地方圖卡成為對話的起點。小車走過一格又一格,參與者也在選擇、合作與分享之間,認識臺南與澎湖的生活樣貌,並讓自己的經驗成為活動中值得被聽見的內容。

一起選目的地、一起想路線,讓每一次到站,都有一個故事可以分享。

計畫名稱:115年臺南分署臺南澎湖地區農村區域亮點計畫
教具名稱:綠照趣旅行—臺南・澎湖36格智慧互動地圖

C、Java、Python 的變數有何不同?

C、Java、Python 的變數有何不同?從「盒子」與「標籤」開始理解

學習程式設計時,我們常把變數想成「裝資料的盒子」。這個比喻對 C 與 Java 很有幫助,但到了 Python,變數更像是貼在物件上的「名稱標籤」。理解這項差異,就能明白為什麼三種語言的宣告方式不同,也能減少型別轉換、輸入處理及物件操作時的錯誤。

先記住一句話:
C 的變數偏向「有固定型別的記憶體空間」;Java 的變數存放「基本值或物件參考」;Python 的變數則是「指向物件的名稱」。

一、三種語言快速比較

比較項目CJavaPython
型別系統靜態型別靜態型別動態型別
使用前是否寫出型別需要需要通常不需要
同一變數名稱能否改指向不同型別不可以不可以可以
字串字元陣列或字元指標String 物件str 物件
記憶體管理程式設計者負責較多垃圾回收機制自動記憶體管理
常數寫法constfinal慣例使用全大寫名稱

二、變數如何宣告?

C 與 Java 在變數名稱前必須標示型別,編譯器會先檢查資料是否符合規定。

C 語言

int age = 20;
float height = 170.5f;
char grade = 'A';

Java

int age = 20;
double height = 170.5;
String name = "小明";

Python

age = 20
height = 170.5
name = "小明"

Python 不必在變數名稱前寫出型別。當程式執行到指定敘述時,名稱會指向右邊建立的物件。

三、型別可以中途改變嗎?

C 與 Java 的變數一經宣告,型別便固定。以下把整數變數改放字串,編譯時就會出錯。

/* C */
int value = 10;
/* value = "水井村";  型別錯誤 */

// Java
int number = 10;
// number = "水井村";  編譯錯誤

Python 的名稱可以重新指向不同型別的物件:

value = 10
value = "水井村"
value = 3.14
重要觀念:這不是同一個物件由整數「變成」字串,而是名稱 value 依序改指向整數、字串與浮點數物件。

四、用「盒子」與「標籤」理解變數

  • C:像準備一個固定規格的盒子,盒子對應一塊記憶體,只能放適合該型別的資料。
  • Java:基本型別變數像盒子;類別型別變數則保存物件的參考。
  • Python:變數像名稱標籤,標籤可以改貼到另一個物件上。
a = 10
b = a
a = 20

print(a)  # 20
print(b)  # 10

一開始,ab 都指向整數物件 10;執行 a = 20 後,只是讓 a 改指向 20,b 仍然指向 10。

五、基本型別與物件參考

Java 特別區分「基本型別」與「參考型別」。例如 int 直接保存整數值,而 String、陣列及自訂類別的變數保存物件參考。Python 中的整數、浮點數、字串與串列等資料都是物件,變數名稱皆與物件建立關聯。

// Java:一個基本型別、一個物件參考
int score = 90;
String place = "水井村";

# Python:名稱分別指向不同物件
score = 90
place = "水井村"

六、常數的表示方式

/* C */
const double PI = 3.14159;

// Java
final double PI = 3.14159;

# Python:全大寫是一種命名慣例,語法並未禁止修改
PI = 3.14159

C 的 const 與 Java 的 final 可以限制重新指定;Python 通常以全大寫提醒程式設計者「這個值不應任意修改」。

七、型別轉換與除法陷阱

/* C */
double result = (double)5 / 2;

// Java
double result = (double) 5 / 2;

# Python
result = float(5) / 2

在 C 與 Java 中,若兩邊都是整數,5 / 2 會先進行整數除法,結果是 2;只要先把其中一個運算元轉為浮點數,結果才會是 2.5。Python 3 的 / 會得到 2.5,而 // 才是整除。

八、Python 初學者最常遇到的輸入問題

Python 的 input() 所取得的資料一律是字串。若要計算,必須先轉為數值型別。

age = input("請輸入年齡:")
print(type(age))       # <class 'str'>

age = int(age)
print(f"明年是 {age + 1} 歲")

若直接寫成 age + 1,就會變成「字串與整數相加」,Python 因而產生型別錯誤。

九、智慧生活情境範例:環境感測資料

假設系統接收到溫度感測器的資料,三種語言都需要清楚知道資料的型別,才能正確進行判斷、運算與上傳。

# Python 範例
location = "水井村智慧養殖池"
temperature = 28.6
device_count = 7
is_online = True

print(f"場域:{location}")
print(f"水溫:{temperature} °C")
print(f"設備數:{device_count}")
print(f"連線狀態:{is_online}")

其中 location 是字串、temperature 是浮點數、device_count 是整數、is_online 是布林值。即使 Python 不要求事先宣告,程式設計者仍應掌握每項資料的意義與型別。

十、課堂練習

  1. 分別用 C、Java 與 Python 宣告姓名、年齡及身高。
  2. 預測 Python 程式 x = 5; y = x; x = 8 執行後,xy 的結果。
  3. 說明為什麼 input() 取得的年齡不能直接加 1。
  4. 為一個智慧農業情境設計四個變數,至少包含字串、整數、浮點數與布林值。

結語

C 重視記憶體與型別控制,Java兼顧型別安全與物件導向,Python 則以簡潔、動態的名稱綁定提高開發效率。

Python 看似不用宣告型別,並不代表型別不重要。相反地,當資料來自鍵盤、感測器、API 或雲端資料庫時,正確理解型別,正是把資料轉化為智慧生活應用的第一步。

2026年9月12日 星期六

樹藝AI故事機 V1.3 操作手冊

實體操作手機網頁 App音檔安裝

樹藝AI故事機 V1.3 操作手冊

本手冊說明樹藝AI故事機的現場實體操作、手機網頁控制與 JQ6500 音檔安裝方式。日常參觀可直接使用前面板按鈕;布展與維護時,可使用手機開啟網頁控制頁進行測試與設定。


前面板配置:左側為 LED 指示燈,中間為超音波感測器,右側為實體按鈕。

一、開機與待機

  1. 接上電源:故事機啟動後,LED 會依序亮起進行自我測試。
  2. 聆聽開機訊息:系統自動播放 004.mp3
  3. 進入待機:綠燈亮起,等待參觀者靠近或工作人員以手機連線。
提示:開機後超音波感測器會有約 5 秒的穩定時間;這段期間紅燈不會因瞬間量測失敗而誤亮。

二、實體操作:給參觀者使用

1. 自動歡迎與離場

  • 參觀者靠近感測範圍後,故事機自動播放 005.mp3 歡迎詞
  • 參觀者離開且持續超過設定時間後,故事機播放 006.mp3 謝謝參觀

2. 三個實體按鈕

按鈕顏色內容音檔
白色作品故事001.mp3
綠色使用材料003.mp3
紅色設計作者002.mp3

歡迎詞或內容音檔播放期間,系統會暫停接受下一個實體按鈕,避免兩段聲音重疊。按鈕採「放開後才能再次觸發」設計,請按一下後放開即可。

三、LED 指示燈判讀

燈色狀態說明
綠燈待機系統已就緒,可等待參觀者或接受操作。
黃燈系統語音正在播放開機、歡迎或謝謝參觀等提示音。
藍燈內容播放正在播放作品故事、使用材料或設計作者。
紅燈感測器異常HC‑SR04 連續量測失敗。請確認感測器供電、GND 共地、TRIG/ECHO 接線與 ECHO 分壓。

四、手機網頁 App:給工作人員使用

  1. 開啟手機 Wi‑Fi:選擇 TreeAIStoryBot
  2. 不需輸入密碼:本版採展示用開放式 AP,手機連上後可能顯示「無網際網路」,這是正常情況。
  3. 開啟瀏覽器:輸入 http://192.168.4.1
  4. 進入控制頁:可查看即時狀態、播放音檔、修改感測門檻、切換模式與測試 LED。
安全提醒:開放式 AP 僅供展場近距離維護使用。請不要在此網頁輸入帳號、密碼、個資或其他敏感資料;展示結束後關閉故事機電源,熱點就會停止。
網頁區塊操作用途
更新狀態重新讀取距離、BUSY、感測器與目前音檔狀態。
播放音檔可個別測試 001~006.mp3。每次點選只播放一次,之後回到首頁。
感測門檻設定靠近距離、離開距離、離開確認秒數,適合依展場空間調整。
測試模式靠近 10 cm 觸發;離開超過 25 cm 並持續 5 秒。適合桌上用手測試。
正式展示靠近 80 cm 觸發;離開超過 120 cm 並持續 5 秒。適合展場導覽。
LED 測試依序點亮綠、黃、藍、紅燈,確認燈與電阻接線。

五、MP3 音檔安裝

JQ6500 對音檔排序較敏感,請使用 FAT32 格式的 microSD 卡,並將音檔放在卡片根目錄。檔名務必使用連續的三位數編號;不要保留 (1)(2) 等電腦自動加上的字樣。

正確檔名內容試聽
001.mp3作品故事
001.mp3
002.mp3設計作者
002.mp3
003.mp3使用材料
003.mp3
004.mp3系統開機訊息
004.mp3
005.mp3歡迎參觀樹藝工坊
005.mp3
006.mp3謝謝參觀
006.mp3
  1. 先將 microSD 卡格式化為 FAT32。
  2. 將本資料夾中的 001.mp3006.mp3 依序複製到 microSD 根目錄。
  3. 確認卡內不要有同編號的舊檔、隱藏音檔或中斷的編號。
  4. 關閉故事機電源後插入 microSD,再重新開機,以 004.mp3 測試是否可正常發聲。
快速故障排除:若沒有聲音,先確認喇叭接在 JQ6500 的 SPK+/SPK−、microSD 檔名連續且正確、模組 VCC 接 5V、所有 GND 共地。若紅燈持續亮,先檢查 HC‑SR04 ECHO 是否已用 1kΩ/2kΩ 分壓後接到 Pico W GP15。




樹藝AI故事機 V1.3:硬體設計與接線的重要觀念

Raspberry Pi Pico WJQ6500HC‑SR04硬體實作

樹藝AI故事機 V1.3:硬體設計與接線的重要觀念

一台可在展場主動歡迎參觀者、播放作品故事,並讓工作人員以手機網頁維護的故事機,並不是把模組接上就完成。真正決定系統是否穩定的,是電壓、共地、訊號方向與逐步測試這些看似基本、卻最容易被忽略的硬體觀念。

一、系統組成與角色分工

元件工作角色
Raspberry Pi Pico W系統控制核心:讀取超音波與按鈕、控制 LED、透過 UART 指揮 JQ6500,也提供 Wi‑Fi 網頁控制。
JQ6500 MP3 模組從 microSD 卡讀取 001~006.mp3,驅動喇叭播放系統訊息、歡迎詞與內容。
HC‑SR04量測參觀者與作品的距離,觸發歡迎詞與離開致謝詞。
三個按鈕與四顆 LED提供參觀者內容選擇與現場可見狀態。

二、完整接腳圖



圖 1.Pico W、JQ6500、HC‑SR04、按鈕與 LED 的接線總覽。

圖中的 Pin 是 Pico W 排針的實體腳位;GP 則是程式中使用的 GPIO 名稱,例如 Pin(14) 代表 GP14,而不是實體 Pin 14。

三、第一原則:所有 GND 必須共地

Pico W、JQ6500、HC‑SR04、按鈕與 LED 必須共用同一個 GND。訊號電壓永遠是相對於 GND 而存在;若模組沒有共地,Pico W 讀到的 0 或 1 可能只是漂浮訊號,造成按鈕亂觸發、BUSY 不穩或超音波距離異常。

實作口訣:先接電源與 GND,再接訊號線;每加入一個模組,都確認它的 GND 已接回 Pico W。

四、5V 模組與 3.3V GPIO:ECHO 一定要分壓

HC‑SR04 以 Pico W 的 VBUS 5V 供電,但 ECHO 腳會輸出約 5V。Pico W GPIO 只能接受 3.3V 邏輯,不能將 ECHO 直接接到 GP15。

HC‑SR04 ECHO → 1 kΩ → GP15
GP15 → 2 kΩ → GND

分壓後的電壓約為 5V × 2kΩ ÷ (1kΩ + 2kΩ) = 3.33V,符合 Pico W GPIO 的安全範圍。

不可省略:把 HC‑SR04 的 ECHO 直接接 Pico W,可能損壞 GPIO。TRIG 是 Pico W 輸出訊號,可直接接到 HC‑SR04 TRIG。

五、UART 有方向:Pico W 指揮 JQ6500

連線用途程式對應
GP0(實體 Pin 1)→ JQ6500 RX送出播放命令UART(0, tx=Pin(0), baudrate=9600)
JQ6500 BUSY → GP20(實體 Pin 26)回報播放中/未播放busy = Pin(20, Pin.IN)
JQ6500 VCC ← Pico VBUS5V 供電硬體接線
SPK+/SPK− → 喇叭音訊輸出不可接到 GND

本作品實測 BUSY 在播放時約 2.4V、未播放時約 0V。因此它可接 Pico W 輸入腳,但不設定 PULL_UPPULL_DOWN;程式再以短暫穩定時間過濾偶發跳動。

六、按鈕採 PULL_UP,LED 必須限流

三個按鈕分別接 GP10、GP11、GP12,另一端都接 GND。程式設定 Pin.IN, Pin.PULL_UP:未按下讀值為 1;按下接地後讀值為 0。V1.3 加入「按鈕放開鎖定」,按鍵必須先回到 1 才可再次播放,避免長按或接點不穩造成重播。

四顆 LED 分別由 GP16~GP19 控制,每顆均要串聯 220~330 Ω 電阻:

Pico GPIO → 220~330 Ω 電阻 → LED 正極(長腳)
LED 負極(短腳)→ GND
GPIO顏色意義
GP16待機
GP17開機、歡迎、致謝等系統語音
GP18作品內容播放
GP19超音波感測器連續量測失敗

紅燈是獨立診斷狀態,不應因為其他燈正在亮就被覆蓋;這是 V1.2 實測後修正的 LED 設計原則。

七、建議的硬體測試順序

  1. 只接 Pico W:確認 Thonny 能執行程式與儲存 main.py
  2. 逐顆測 LED:確認開機依序亮綠、黃、藍、紅。
  3. 測 HC‑SR04:先列印距離,確認手靠近/移開數值合理,再加入濾波與離場判定。
  4. 測 JQ6500:確認 microSD 音檔連續編號 001~006,先播 004 開機訊息,再測各內容音檔。
  5. 測 BUSY:列印原始與穩定讀值,確認播放中為 1、未播放為 0。
  6. 接按鈕:逐一確認 GP10、GP11、GP12 按下只播放一次。
  7. 最後測 Wi‑Fi:手機連上 TreeAIStoryBot,開啟 http://192.168.4.1

八、接線完成前檢查表

  • 所有模組 GND 已共地。
  • HC‑SR04 ECHO 已經過 1kΩ/2kΩ 分壓。
  • JQ6500 喇叭接在 SPK+、SPK−,沒有接到 GND。
  • 四顆 LED 都各自串聯 220~330Ω 電阻。
  • 按鈕另一端都接 GND,程式使用 PULL_UP。
  • BUSY 接 GP20,且輸入腳未設 PULL_UP 或 PULL_DOWN。
  • microSD 音檔使用連續編號 001.mp3~006.mp3。
結語:樹藝AI故事機的互動看似由語音與按鈕完成,但背後依賴安全電壓、正確訊號方向、共同接地,以及「每接一項就測一項」的工程習慣。這些基礎做好後,故事機才能在展場穩定地把樹藝工坊的作品、材料與創作故事說給每一位參觀者聽。

2026年9月6日 星期日

樹藝AI故事機 V1.3:Pico W 開放式 Wi‑Fi 網頁控制測試紀錄

樹藝AI故事機Raspberry Pi Pico WWi‑Fi 網頁控制

樹藝AI故事機 V1.3:Pico W 開放式 Wi‑Fi 網頁控制測試紀錄

這一版讓 Raspberry Pi Pico W 自行建立一個近距離展示用 Wi‑Fi 熱點。工作人員以手機連上後,即可在網頁測試音檔、調整超音波距離門檻、切換模式並檢查 LED;現場按鈕與自動導覽仍照常運作。

一、測試目標

樹藝AI故事機原本以按鈕與超音波感測器提供導覽。V1.3 加入手機網頁控制,讓布展與維護時不必接上電腦,也能快速確認音檔、感測器與燈號是否正常。

項目測試內容結果
手機連線手機連至 Pico W 建立的熱點採無密碼 AP 後可成功連線
網頁控制瀏覽 http://192.168.4.1可查看狀態與發送控制命令
音檔控制播放故事、材料、作者、系統提示音可正常單次播放
感測門檻修改靠近、離開與確認秒數可於展示現場直接調整

二、為什麼最後採用「不輸入密碼」?

一開始嘗試讓 Pico W 的 AP 使用 Wi‑Fi 金鑰(key)與 WPA/WPA2 安全模式;但 iPhone 將該熱點顯示為 WEP,輸入密碼後仍無法完成連線。這不是使用者輸入錯誤,而是原始 Pico W 的 MicroPython AP 加密模式存在相容性問題。

測試決策:本作品的網頁只在展場近距離維護使用,不傳送個資或敏感資料。因此先採用最穩定的開放式 AP,讓工作人員可立即連線完成測試。若未來需要加密,建議改採 Pico W 連上既有路由器或手機分享熱點的 STA 模式,再由路由器處理 WPA2/WPA3 密碼。

MicroPython 社群在 2025 年仍有 Pico W AP 加上金鑰後無法正常啟用安全連線、甚至被辨識為 WEP 的回報;因此本次以實測可用性優先。參考:MicroPython Pico W AP mode runs only in open unsecured mode

三、手機操作流程

  1. 開啟樹藝AI故事機電源,完成 LED 逐顆自我測試與系統開機語音。
  2. 在手機 Wi‑Fi 清單中選擇 TreeAIStoryBot
  3. 本版為展示用開放式熱點,不需輸入密碼
  4. 開啟 Safari、Chrome 等瀏覽器,輸入 http://192.168.4.1
  5. 進入控制頁後,可播放音檔、套用感測門檻、切換模式,並使用「更新狀態」讀取最新資料。
圖 1.手機連上 TreeAIStoryBot 後開啟的 V1.3 網頁控制頁面。

四、網頁控制頁面說明

區塊用途
即時狀態顯示訪客狀態、目前音檔、超音波原始/濾波距離、JQ6500 BUSY 與感測器健康狀態。
播放音檔可分別測試作品故事、使用材料、設計作者、開機訊息、歡迎詞與謝謝參觀。
感測門檻可臨時設定靠近距離、離開距離,以及離開確認秒數。
維護工具切換桌上測試/正式展示,或逐顆執行 LED 測試。
模式說明直接保留兩種模式的距離條件,讓現場操作不必翻找程式碼。

五、測試模式與正式展示模式

模式歡迎詞觸發離開判定適用場合
桌上測試小於 10 cm大於 25 cm 並持續 5 秒以手靠近感測器,快速測試功能
正式展示小於 80 cm大於 120 cm 並持續 5 秒展場參觀者靠近作品時自動導覽

六、測試過程中的兩個重要修正

  1. Wi‑Fi 加密相容性:先嘗試 AP 密碼,iPhone 顯示 WEP 且無法連線;改為開放式展示熱點後,手機可正常連線與控制。
  2. 網頁重播問題:初版網頁為了顯示即時狀態而自動刷新,但播放網址也被重送,造成音檔重播。修正後,控制命令送出會回到首頁,並改由「更新狀態」按鈕手動更新。
  3. 實體按鈕鎖定:按鈕必須先放開,才可再次觸發,避免長按、接點不穩或接線狀況造成連續播放。
  4. LED 與感測器:開機 LED 逐顆亮起後會明確關閉;HC‑SR04 保留 5 秒穩定時間,避免剛開機就誤亮紅燈。

七、開放式 AP 的使用原則

  • 僅在可信任的展場或課堂現場短距離使用。
  • 不要透過此網頁輸入個資、帳號、密碼或其他敏感內容。
  • 展示結束後關閉故事機電源,熱點也會停止。
  • 若未來要加入雲端服務或跨網路控制,改用既有 WPA2/WPA3 路由器或手機熱點。
測試結論:樹藝AI故事機 V1.3 的開放式 Wi‑Fi 網頁控制已可正常使用。它將「現場測試」從接電腦改為手機操作,同時保留超音波自動導覽、三個實體按鈕與 LED 狀態提示;這是以作品可用性為優先的實作選擇。

八、程式碼

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
from machine import Pin, UART, time_pulse_us
import time
import network
import socket

# ==================================================
# 樹藝AI故事機 V1.3
# Pico W + Wi-Fi 網頁控制 + JQ6500 BUSY + HC-SR04
# 三按鈕 + 四 LED
# ==================================================

# --------------------------------------------------
# Wi-Fi 設定:Pico W 會建立自己的無線基地台
# 手機連上後,瀏覽器開啟 http://192.168.4.1
# 原始 Pico W 的 MicroPython AP 加密模式與 iPhone 相容性不穩,
# 本展示版採用無密碼 AP,確保現場能連線。
# --------------------------------------------------
WIFI_SSID = "TreeAIStoryBot"

# --------------------------------------------------
# JQ6500 UART:Pico W GP0 -> JQ6500 RX
# JQ6500 BUSY -> Pico W GP20(播放中約 2.4 V = 1)
# --------------------------------------------------
jq6500 = UART(0, baudrate=9600, tx=Pin(0))
busy = Pin(20, Pin.IN)  # BUSY 不使用 PULL_UP / PULL_DOWN

# HC-SR04:ECHO 經分壓後才可接到 GP15
trig = Pin(14, Pin.OUT)
echo = Pin(15, Pin.IN)

# 三個按鈕:另一端都接 GND
button_story = Pin(10, Pin.IN, Pin.PULL_UP)
button_material = Pin(11, Pin.IN, Pin.PULL_UP)
button_author = Pin(12, Pin.IN, Pin.PULL_UP)

# LED 都要串 220 ~ 330 ohm 電阻
led_ready = Pin(16, Pin.OUT)      # 綠:待機
led_welcome = Pin(17, Pin.OUT)    # 黃:系統提示音
led_playing = Pin(18, Pin.OUT)    # 藍:內容播放
led_error = Pin(19, Pin.OUT)      # 紅:超音波異常

# microSD 卡須連續放入 001.mp3 ~ 006.mp3
FILE_STORY = 1
FILE_AUTHOR = 2
FILE_MATERIAL = 3
FILE_BOOT = 4
FILE_WELCOME = 5
FILE_GOODBYE = 6

# --------------------------------------------------
# 展示參數:可直接由網頁修改(重新開機後回到此處設定)
# --------------------------------------------------
TEST_MODE = True
if TEST_MODE:
    ENTER_DISTANCE = 10
    LEAVE_DISTANCE = 25
else:
    ENTER_DISTANCE = 80
    LEAVE_DISTANCE = 120

LEAVE_CONFIRM_MS = 5000
BUTTON_DEBOUNCE_MS = 500
PLAY_START_LOCK_MS = 500
BUSY_DEBOUNCE_MS = 300
FILTER_SIZE = 7

# 系統狀態
distance_buffer = []
raw_distance = 999
filtered_distance = 999
person_is_here = False
far_since = None
last_button_time = 0
# 實體按鈕必須先回到放開(1),下一次按下(0)才會再觸發。
# 可避免按鍵長按、接點不穩或短路造成播放結束後重播。
button_latched = [False, False, False]
current_audio_type = "idle"
current_audio_number = 0
audio_start_lock_until = 0
busy_raw = busy.value()
busy_stable = busy_raw
busy_changed_at = time.ticks_ms()
sensor_fail_count = 0
sensor_error = False
sensor_monitor_started_at = None
SENSOR_STARTUP_GRACE_MS = 5000
server = None
ap = None


def time_is_active(end_time):
    return time.ticks_diff(time.ticks_ms(), end_time) < 0


def leds_off():
    led_ready.value(0)
    led_welcome.value(0)
    led_playing.value(0)
    led_error.value(0)


def led_self_test():
    """開機時逐顆測試 LED。"""
    print("LED 自我測試中……")
    leds_off()
    for led in (led_ready, led_welcome, led_playing, led_error):
        led.value(1)
        time.sleep_ms(400)
        led.value(0)
        time.sleep_ms(150)
    leds_off()


def update_busy_state():
    """BUSY 讀值連續穩定後,才更新正式播放狀態。"""
    global busy_raw, busy_stable, busy_changed_at

    now = time.ticks_ms()
    reading = busy.value()
    if reading != busy_raw:
        busy_raw = reading
        busy_changed_at = now

    if (busy_raw != busy_stable and
            time.ticks_diff(now, busy_changed_at) >= BUSY_DEBOUNCE_MS):
        busy_stable = busy_raw

    return busy_stable


def is_audio_playing():
    """命令送出後給 JQ6500 一小段 BUSY 穩定時間。"""
    if time_is_active(audio_start_lock_until):
        return True
    return update_busy_state() == 1


def is_managed_audio_playing():
    """只判斷本程式送出的音檔,忽略待機時 BUSY 的雜訊。"""
    if current_audio_type == "idle":
        return False
    return is_audio_playing()


def update_leds():
    """
    使用實測後的『獨立狀態』邏輯:
    紅燈是感測器診斷,獨立於播放狀態;
    綠、黃、藍才表示待機或音檔類型。
    """
    if sensor_error:
        led_error.value(1)
    else:
        led_error.value(0)

    if is_audio_playing():
        led_ready.value(0)
        if current_audio_type in ("boot", "welcome", "goodbye"):
            led_welcome.value(1)
            led_playing.value(0)
        else:
            led_playing.value(1)
            led_welcome.value(0)
    else:
        led_playing.value(0)
        led_welcome.value(0)
        led_ready.value(1)


def play_file(number, audio_type, source="系統"):
    """播放 001~006.mp3,並記錄音檔種類供燈號與網頁使用。"""
    global current_audio_type, current_audio_number, audio_start_lock_until

    if number < 1 or number > 6:
        return

    command = bytes([0x7E, 0x04, 0x03, 0x00, number, 0xEF])
    jq6500.write(command)
    current_audio_type = audio_type
    current_audio_number = number
    audio_start_lock_until = time.ticks_add(time.ticks_ms(), PLAY_START_LOCK_MS)
    print("播放音檔:", number, ";來源:", source)


def get_distance():
    trig.value(0)
    time.sleep_us(2)
    trig.value(1)
    time.sleep_us(10)
    trig.value(0)
    try:
        duration = time_pulse_us(echo, 1, 30000)
        if duration < 0:
            return 999
        return duration * 0.0343 / 2
    except OSError:
        return 999


def update_distance():
    """更新中位數濾波距離,以及連續失敗的紅燈診斷。"""
    global raw_distance, filtered_distance, sensor_fail_count, sensor_error
    raw_distance = get_distance()

    if (sensor_monitor_started_at is not None and
            time.ticks_diff(time.ticks_ms(), sensor_monitor_started_at) <
            SENSOR_STARTUP_GRACE_MS):
        # HC-SR04 剛開機先給 5 秒穩定時間,不以失敗點亮紅燈。
        sensor_fail_count = 0
        sensor_error = False
    elif raw_distance >= 999:
        sensor_fail_count += 1
    else:
        sensor_fail_count = 0
    sensor_error = sensor_fail_count >= 5

    distance_buffer.append(raw_distance)
    if len(distance_buffer) > FILTER_SIZE:
        distance_buffer.pop(0)
    values = sorted(distance_buffer)
    filtered_distance = values[len(values) // 2]


def check_buttons():
    global last_button_time, button_latched
    now = time.ticks_ms()

    button_values = [
        button_story.value(),
        button_material.value(),
        button_author.value()
    ]

    # 只要該按鈕已放開,即解除該按鈕的鎖定。
    for index in range(3):
        if button_values[index] == 1:
            button_latched[index] = False

    if is_managed_audio_playing():
        return
    if time.ticks_diff(now, last_button_time) < BUTTON_DEBOUNCE_MS:
        return

    if button_values[0] == 0 and not button_latched[0]:
        print("選擇:作品故事")
        button_latched[0] = True
        play_file(FILE_STORY, "content", "實體按鈕:作品故事")
        last_button_time = now
    elif button_values[1] == 0 and not button_latched[1]:
        print("選擇:使用材料")
        button_latched[1] = True
        play_file(FILE_MATERIAL, "content", "實體按鈕:使用材料")
        last_button_time = now
    elif button_values[2] == 0 and not button_latched[2]:
        print("選擇:設計作者")
        button_latched[2] = True
        play_file(FILE_AUTHOR, "content", "實體按鈕:設計作者")
        last_button_time = now


def url_value(query, key):
    """從 a=1&b=2 格式取出數值。"""
    for item in query.split("&"):
        pair = item.split("=", 1)
        if len(pair) == 2 and pair[0] == key:
            return pair[1]
    return None


def clamp_int(value, minimum, maximum):
    try:
        value = int(value)
        if value < minimum:
            return minimum
        if value > maximum:
            return maximum
        return value
    except (ValueError, TypeError):
        return None


def handle_web_action(path):
    """處理網頁按鈕命令,回傳簡短結果文字。"""
    global ENTER_DISTANCE, LEAVE_DISTANCE, LEAVE_CONFIRM_MS, TEST_MODE

    if "?" in path:
        route, query = path.split("?", 1)
    else:
        route, query = path, ""

    if route == "/play":
        number = clamp_int(url_value(query, "file"), 1, 6)
        if number is not None:
            if number == FILE_BOOT:
                play_file(number, "boot", "網頁控制")
            elif number == FILE_WELCOME:
                play_file(number, "welcome", "網頁控制")
            elif number == FILE_GOODBYE:
                play_file(number, "goodbye", "網頁控制")
            else:
                play_file(number, "content", "網頁控制")
            return "已播放 %03d.mp3" % number

    elif route == "/set":
        enter = clamp_int(url_value(query, "enter"), 3, 300)
        leave = clamp_int(url_value(query, "leave"), 5, 400)
        confirm = clamp_int(url_value(query, "confirm"), 1, 30)
        if enter is not None and leave is not None and leave > enter:
            ENTER_DISTANCE = enter
            LEAVE_DISTANCE = leave
        if confirm is not None:
            LEAVE_CONFIRM_MS = confirm * 1000
        return "展示參數已更新"

    elif route == "/mode":
        mode = url_value(query, "value")
        if mode == "test":
            TEST_MODE = True
            ENTER_DISTANCE = 10
            LEAVE_DISTANCE = 25
            return "已切換為桌上測試模式"
        if mode == "show":
            TEST_MODE = False
            ENTER_DISTANCE = 80
            LEAVE_DISTANCE = 120
            return "已切換為正式展示模式"

    elif route == "/ledtest":
        led_self_test()
        return "LED 自我測試完成"

    return ""


def page_html(message):
    """控制頁每兩秒刷新一次,方便手機查看即時狀態。"""
    audio = "播放中" if is_audio_playing() else "待機"
    visitor = "有人靠近" if person_is_here else "尚無訪客"
    error = "異常" if sensor_error else "正常"
    mode = "桌上測試" if TEST_MODE else "正式展示"
    return """<!doctype html><html><head><meta charset='utf-8'>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<title>樹藝AI故事機</title>
<style>body{font-family:sans-serif;margin:16px;background:#f4f1ea;color:#27372d}h1{font-size:25px}.box{background:#fff;padding:14px;margin:12px 0;border-radius:12px;box-shadow:0 1px 4px #bbb}button{padding:11px;margin:4px;border:0;border-radius:8px;background:#356b45;color:#fff;font-size:16px}button.alt{background:#81653e}input{width:58px;padding:7px}.ok{color:#176b3a}.warn{color:#a43b24}</style></head><body>
<h1>樹藝AI故事機 V1.3</h1><p><a href='/'><button class='alt'>更新狀態</button></a></p>
<div class='box'><b>即時狀態</b><br>訪客:%s 音訊:%s<br>距離:原始 %.1f cm/濾波 %.1f cm<br>BUSY:原始 %d/穩定 %d 感測器:<span class='%s'>%s</span><br>目前音檔:%03d.mp3 模式:%s</div>
<div class='box'><b>播放音檔</b><br>
<a href='/play?file=1'><button>作品故事</button></a><a href='/play?file=3'><button>使用材料</button></a><a href='/play?file=2'><button>設計作者</button></a><br>
<a href='/play?file=4'><button class='alt'>開機訊息</button></a><a href='/play?file=5'><button class='alt'>歡迎詞</button></a><a href='/play?file=6'><button class='alt'>謝謝參觀</button></a></div>
<div class='box'><b>感測門檻</b><br><form action='/set'>靠近 <input name='enter' value='%d'> cm 離開 <input name='leave' value='%d'> cm 確認 <input name='confirm' value='%d'> 秒 <button type='submit'>套用</button></form></div>
<div class='box'><b>維護工具</b><br><a href='/mode?value=test'><button>測試模式</button></a><a href='/mode?value=show'><button>正式展示</button></a><a href='/ledtest'><button class='alt'>LED 測試</button></a></div>
<div class='box'><b>模式說明</b><br><br><b>測試模式</b>:手靠近 <b>10 cm</b> 會播放歡迎詞;距離大於 <b>25 cm</b> 並持續 5 秒,才判定離開。適合桌上以手測試。<br><br><b>正式展示模式</b>:參觀者靠近 <b>80 cm</b> 會播放歡迎詞;距離大於 <b>120 cm</b> 並持續 5 秒,才判定離開。適合展場導覽。<br><br>兩種模式的按鈕、網頁播放、LED 與音檔功能相同;可在「感測門檻」欄位依現場空間微調。</div>
<div class='box %s'>%s</div></body></html>""" % (visitor, audio, raw_distance, filtered_distance, busy.value(), busy_stable, "warn" if sensor_error else "ok", error, current_audio_number, mode, ENTER_DISTANCE, LEAVE_DISTANCE, LEAVE_CONFIRM_MS // 1000, "ok" if message else "", message or "網頁控制已連線")


def start_wifi():
    global ap, server
    ap = network.WLAN(network.AP_IF)
    ap.active(False)
    time.sleep_ms(300)

    # 無密碼 AP(security=0)是 Pico W + iPhone 最穩定的組合。
    # 不設定 key,避免 iPhone 將熱點辨識為 WEP。
    ap.config(essid=WIFI_SSID, security=0)
    ap.active(True)

    time.sleep(1)
    print("Wi-Fi 名稱:", WIFI_SSID)
    print("Wi-Fi 安全模式:開放式(展示用,無需輸入密碼)")
    print("網頁位址:http://", ap.ifconfig()[0], sep="")

    address = socket.getaddrinfo("0.0.0.0", 80)[0][-1]
    server = socket.socket()
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind(address)
    server.listen(1)
    server.settimeout(0)  # 主程式不可被網頁連線卡住


def handle_web_request():
    """每一圈主程式只處理一個連線;沒有連線時立即返回。"""
    try:
        client, _ = server.accept()
    except OSError:
        return

    try:
        client.settimeout(0.5)
        request = client.recv(512).decode()
        first = request.split("\r\n", 1)[0]
        parts = first.split(" ")
        path = parts[1] if len(parts) >= 2 else "/"

        # 網頁有自動刷新。控制命令執行後必須轉回首頁,
        # 否則瀏覽器會持續刷新 /play?file=...,造成重複播放。
        if (path.startswith("/play?") or path.startswith("/set?") or
                path.startswith("/mode?") or path == "/ledtest"):
            handle_web_action(path)
            response = ("HTTP/1.1 303 See Other\r\n"
                        "Location: /\r\n"
                        "Content-Length: 0\r\n"
                        "Connection: close\r\n\r\n")
        else:
            body = page_html("")
            response = ("HTTP/1.1 200 OK\r\n"
                        "Content-Type: text/html; charset=utf-8\r\n"
                        "Connection: close\r\n\r\n" + body)
        client.send(response)
    except OSError:
        pass
    finally:
        client.close()


# ==================================================
# 開機程序
# ==================================================
print("樹藝AI故事機 V1.3 啟動中……")
led_self_test()
start_wifi()
play_file(FILE_BOOT, "boot", "開機程序")

while is_managed_audio_playing():
    update_leds()
    handle_web_request()
    time.sleep_ms(100)

update_leds()
sensor_monitor_started_at = time.ticks_ms()
print("系統已就緒,等待參觀者靠近……")


# ==================================================
# 主程式:網頁、超音波與實體按鈕並行運作
# ==================================================
while True:
    update_distance()
    now = time.ticks_ms()
    update_leds()
    handle_web_request()

    if not sensor_error:
        # 首次靠近:播放歡迎詞
        if (filtered_distance < ENTER_DISTANCE and not person_is_here and
                not is_managed_audio_playing()):
            print("參觀者靠近:", filtered_distance, "cm")
            person_is_here = True
            far_since = None
            play_file(FILE_WELCOME, "welcome", "超音波靠近")

        if person_is_here and not is_managed_audio_playing():
            check_buttons()

            # 離開須連續超過門檻一段時間,才播放致謝詞
            if filtered_distance < LEAVE_DISTANCE:
                far_since = None
            elif far_since is None:
                far_since = now
            elif time.ticks_diff(now, far_since) >= LEAVE_CONFIRM_MS:
                print("參觀者已離開:", filtered_distance, "cm")
                person_is_here = False
                far_since = None
                play_file(FILE_GOODBYE, "goodbye", "超音波離開")

    time.sleep_ms(150)

從「燈號優先序」到「訊號並列」:樹藝AI故事機 V1.2 的人機協作測試紀錄

從「燈號優先序」到「訊號並列」:樹藝AI故事機 V1.2 的人機協作測試紀錄

Raspberry Pi Pico W × JQ6500 BUSY × HC-SR04 × MicroPython

樹藝AI故事機的目標,是讓自然素材作品能主動迎接參觀者,並以語音介紹作品故事、使用材料與設計作者。這次開發最值得記錄的,並不是「AI 一次產生了正確程式」,而是透過實體接線、量測與反覆測試,修正了 AI 最初對燈號邏輯的假設,完成可穩定運作的 V1.2。

本次核心收穫:程式設計不能只看「看起來合理」的控制邏輯。當硬體中的訊號屬於不同層次時,不能強迫它們排成單一優先序;必須由人透過實測,辨識哪些狀態應該互斥、哪些訊號應該並列呈現。

一、樹藝AI故事機的功能

互動情境系統行為音檔
作品通電開機播放系統開機訊息004.mp3
參觀者靠近播放歡迎詞,引導選擇故事、材料或作者005.mp3
按下作品故事按鈕播放貓頭鷹博士的作品故事001.mp3
按下設計作者按鈕播放樹藝工坊李永謨工藝師介紹002.mp3
按下使用材料按鈕播放自然素材與創作材料說明003.mp3
參觀者真正離開連續五秒未偵測到近距離物體後,播放致謝詞006.mp3

二、從「推測」到「實測」:BUSY 訊號帶來的轉折

JQ6500 模組可由 Pico W 的 UART 送出播放命令,但如果沒有播放狀態回授,程式只能以「預估秒數」判斷音檔是否播完。為了讓燈號真正反映播放情況,本次將 JQ6500 的 BUSY 腳位接到 Pico W 的 GP20。

實測項目量測結果程式判斷
JQ6500 播放中BUSY 約 2.4V1,代表正在播放
JQ6500 未播放BUSY 約 0V0,代表未播放
BUSY 原始讀值偶爾在 0、1 間跳動需連續穩定 300 ms 才更新狀態

因此程式加入兩道保護:

  1. 播放啟動保護時間:送出播放命令後,等待 500 ms,避免 BUSY 尚未切換就被誤判為播放結束。
  2. BUSY 去彈跳:原始讀值必須連續穩定 300 ms,程式才接受新的播放狀態。

三、AI 初版邏輯的問題:所有燈號被迫只能選一個

AI 初版建議以「紅燈異常 > 黃燈系統語音 > 藍燈作品內容 > 綠燈待機」的優先序處理燈號。這種寫法適合每一種狀態都完全互斥的系統,但在實際硬體中,紅燈代表的是感測器診斷訊號,黃燈與藍燈代表的是語音互動狀態;兩者並不屬於同一個維度。

初版:單一優先序

程式一旦判定紅燈優先,就可能關閉黃燈、藍燈或綠燈。這雖然讓「某一刻只有一顆燈亮」,但會把不同來源的資訊混在一起,現場不容易理解真正狀態。

實測修正:訊號並列

紅燈獨立表示感測器異常;播放狀態再決定黃燈、藍燈或綠燈。如此可同時看見「系統正在播音」與「感測器出現異常」,不會互相遮蔽。

修正後的核心概念是:

# 紅燈:獨立的診斷訊號
if sensor_error:
    led_error.value(1)
else:
    led_error.value(0)

# 黃、藍、綠燈:依播放狀態顯示
if is_audio_playing():
    ...
else:
    ...
人機協作的關鍵:AI 可以快速提出架構、程式與可能的修正方向;但「紅燈是否應與其他燈號並列」並不是純粹語法問題,而是現場展示者對使用情境、可讀性與硬體行為的判斷。這個判斷必須回到實際作品前完成。

四、實測流程:從單元測試走向完整互動

測試階段做法得到的結果
JQ6500 語音測試依序播放 001 至 006 音檔。確認 microSD 必須連續命名,避免缺少音檔造成指定播放異常。
BUSY 訊號量測播放與停止時,以電表觀察 BUSY 電壓。確認播放為 2.4V、停止為 0V,建立播放狀態判斷基礎。
超音波測試觀察原始值與七筆中位數濾波後距離。可排除偶發的遠方反射值,減少重複迎賓與離場誤判。
近距離桌上測試設定靠近 10 cm、離開 25 cm。可在桌上快速重複觸發,不必每次走到正式展示距離。
LED 整合測試觀察綠、黃、藍、紅燈與 BUSY 的對應。發現單一優先序容易誤判,改為診斷燈與互動燈並列處理。

五、V1.2 燈號定義

LED接腳意義
綠燈GP16待機中,可等待參觀者靠近或選擇內容。
黃燈GP17播放開機訊息、歡迎詞或謝謝參觀。
藍燈GP18播放作品故事、使用材料或設計作者。
紅燈GP19超音波連續五次量測失敗的診斷提示,可與播放狀態並列。

六、測試成功的 V1.2 程式

以下程式使用 Pico W 的 GP20 讀取 JQ6500 BUSY,並加入 BUSY 穩定判斷、超音波中位數濾波、近距離測試模式,以及修正後的 LED 並列邏輯。

from machine import Pin, UART, time_pulse_us
import time

jq6500 = UART(0, baudrate=9600, tx=Pin(0))
busy = Pin(20, Pin.IN)

trig = Pin(14, Pin.OUT)
echo = Pin(15, Pin.IN)

button_story = Pin(10, Pin.IN, Pin.PULL_UP)
button_material = Pin(11, Pin.IN, Pin.PULL_UP)
button_author = Pin(12, Pin.IN, Pin.PULL_UP)

led_ready = Pin(16, Pin.OUT)
led_welcome = Pin(17, Pin.OUT)
led_playing = Pin(18, Pin.OUT)
led_error = Pin(19, Pin.OUT)

FILE_STORY = 1
FILE_AUTHOR = 2
FILE_MATERIAL = 3
FILE_BOOT = 4
FILE_WELCOME = 5
FILE_GOODBYE = 6

TEST_MODE = True

if TEST_MODE:
    ENTER_DISTANCE = 10
    LEAVE_DISTANCE = 25
else:
    ENTER_DISTANCE = 80
    LEAVE_DISTANCE = 120

LEAVE_CONFIRM_MS = 5000
BUTTON_DEBOUNCE_MS = 500
PLAY_START_LOCK_MS = 500
BUSY_DEBOUNCE_MS = 300
FILTER_SIZE = 7
DEBUG = False

distance_buffer = []
person_is_here = False
far_since = None
last_button_time = 0

current_audio_type = "idle"
audio_start_lock_until = 0

busy_raw = busy.value()
busy_stable = busy_raw
busy_changed_at = time.ticks_ms()

sensor_fail_count = 0
sensor_error = False


def time_is_active(end_time):
    return time.ticks_diff(time.ticks_ms(), end_time) < 0


def update_busy_state():
    global busy_raw, busy_stable, busy_changed_at

    now = time.ticks_ms()
    reading = busy.value()

    if reading != busy_raw:
        busy_raw = reading
        busy_changed_at = now

    if (
        busy_raw != busy_stable
        and time.ticks_diff(now, busy_changed_at) >= BUSY_DEBOUNCE_MS
    ):
        busy_stable = busy_raw

    return busy_stable


def is_audio_playing():
    if time_is_active(audio_start_lock_until):
        return True

    return update_busy_state() == 1


def is_managed_audio_playing():
    if current_audio_type == "idle":
        return False

    return is_audio_playing()


def update_leds():
    global current_audio_type

    # 紅燈是獨立診斷訊號,不以優先序排除其他燈號
    if sensor_error:
        led_error.value(1)
    else:
        led_error.value(0)

    # 黃、藍、綠燈表示目前互動狀態
    if is_audio_playing():
        led_ready.value(0)

        if current_audio_type in (
            "boot",
            "welcome",
            "goodbye"
        ):
            led_welcome.value(1)
        else:
            led_playing.value(1)

    else:
        led_playing.value(0)
        led_welcome.value(0)
        led_ready.value(1)


def play_file(number, audio_type):
    global current_audio_type, audio_start_lock_until

    command = bytes([0x7E, 0x04, 0x03, 0x00, number, 0xEF])
    jq6500.write(command)

    current_audio_type = audio_type
    audio_start_lock_until = time.ticks_add(
        time.ticks_ms(),
        PLAY_START_LOCK_MS
    )

    print("播放音檔:", number)


def get_distance():
    trig.value(0)
    time.sleep_us(2)

    trig.value(1)
    time.sleep_us(10)
    trig.value(0)

    try:
        duration = time_pulse_us(echo, 1, 30000)

        if duration < 0:
            return 999

        return duration * 0.0343 / 2

    except OSError:
        return 999


def get_filtered_distance():
    global sensor_fail_count, sensor_error

    raw_distance = get_distance()

    if raw_distance >= 999:
        sensor_fail_count += 1
    else:
        sensor_fail_count = 0

    sensor_error = sensor_fail_count >= 5

    distance_buffer.append(raw_distance)

    if len(distance_buffer) > FILTER_SIZE:
        distance_buffer.pop(0)

    sorted_distances = sorted(distance_buffer)
    filtered_distance = sorted_distances[
        len(sorted_distances) // 2
    ]

    return raw_distance, filtered_distance


def check_buttons():
    global last_button_time

    now = time.ticks_ms()

    if is_managed_audio_playing():
        return

    if time.ticks_diff(now, last_button_time) < BUTTON_DEBOUNCE_MS:
        return

    if button_story.value() == 0:
        print("選擇:作品故事")
        play_file(FILE_STORY, "content")
        last_button_time = now

    elif button_material.value() == 0:
        print("選擇:使用材料")
        play_file(FILE_MATERIAL, "content")
        last_button_time = now

    elif button_author.value() == 0:
        print("選擇:設計作者")
        play_file(FILE_AUTHOR, "content")
        last_button_time = now


print("樹藝AI故事機 V1.2 啟動中……")
play_file(FILE_BOOT, "boot")

while is_managed_audio_playing():
    update_leds()
    time.sleep_ms(100)

update_leds()
print("系統已就緒,等待參觀者靠近……")


while True:
    raw_distance, distance = get_filtered_distance()
    now = time.ticks_ms()

    update_leds()

    if DEBUG:
        print(
            "原始:", raw_distance,
            "cm;濾波後:", distance,
            "cm;BUSY原始:", busy.value(),
            ";BUSY穩定:", busy_stable
        )

    if sensor_error:
        time.sleep_ms(150)
        continue

    if (
        distance < ENTER_DISTANCE
        and not person_is_here
        and not is_managed_audio_playing()
    ):
        print("參觀者靠近:", distance, "cm")
        person_is_here = True
        far_since = None
        play_file(FILE_WELCOME, "welcome")

    if person_is_here:
        if not is_managed_audio_playing():
            check_buttons()

            if distance < LEAVE_DISTANCE:
                far_since = None

            else:
                if far_since is None:
                    far_since = now

                elif time.ticks_diff(
                    now,
                    far_since
                ) >= LEAVE_CONFIRM_MS:
                    print("參觀者已離開:", distance, "cm")
                    person_is_here = False
                    far_since = None
                    play_file(FILE_GOODBYE, "goodbye")

    time.sleep_ms(150)

七、可複製的協作方法

  1. 先讓 AI 建立初版:快速完成接線表、程式架構、可能的除錯方向。
  2. 分段測試硬體:先測音檔、再測按鈕、再測超音波,最後才整合燈號。
  3. 量測而不猜測:以電表確認 BUSY 在播放時為 2.4V、停止時為 0V,再寫程式判斷。
  4. 記錄異常現象:例如 BUSY 讀值跳動、綠燈閃爍、超音波量到背景反射。
  5. 修正模型,而非只修補結果:這次不是單純改亮燈順序,而是重新定義「診斷訊號」與「互動狀態」應採並列處理。
  6. 以可運作版本收斂:將測試成功的程式、音檔編號、接線與測試設定完整保存。
結語:樹藝AI故事機讓自然素材作品可以主動說故事;而這次 V1.2 的測試也說明,AI 的價值不在於取代人的判斷,而在於加速提出可測試的方案。真正讓作品變得可靠的,是 AI 的快速生成、人的現場觀察,以及一次次回到硬體與使用情境的驗證。