從「燈號優先序」到「訊號並列」:樹藝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.4V | 1,代表正在播放 |
| JQ6500 未播放 | BUSY 約 0V | 0,代表未播放 |
| BUSY 原始讀值 | 偶爾在 0、1 間跳動 | 需連續穩定 300 ms 才更新狀態 |
因此程式加入兩道保護:
- 播放啟動保護時間:送出播放命令後,等待 500 ms,避免 BUSY 尚未切換就被誤判為播放結束。
- BUSY 去彈跳:原始讀值必須連續穩定 300 ms,程式才接受新的播放狀態。
三、AI 初版邏輯的問題:所有燈號被迫只能選一個
AI 初版建議以「紅燈異常 > 黃燈系統語音 > 藍燈作品內容 > 綠燈待機」的優先序處理燈號。這種寫法適合每一種狀態都完全互斥的系統,但在實際硬體中,紅燈代表的是感測器診斷訊號,黃燈與藍燈代表的是語音互動狀態;兩者並不屬於同一個維度。
初版:單一優先序
程式一旦判定紅燈優先,就可能關閉黃燈、藍燈或綠燈。這雖然讓「某一刻只有一顆燈亮」,但會把不同來源的資訊混在一起,現場不容易理解真正狀態。
實測修正:訊號並列
紅燈獨立表示感測器異常;播放狀態再決定黃燈、藍燈或綠燈。如此可同時看見「系統正在播音」與「感測器出現異常」,不會互相遮蔽。
修正後的核心概念是:
# 紅燈:獨立的診斷訊號
if sensor_error:
led_error.value(1)
else:
led_error.value(0)
# 黃、藍、綠燈:依播放狀態顯示
if is_audio_playing():
...
else:
...四、實測流程:從單元測試走向完整互動
| 測試階段 | 做法 | 得到的結果 |
|---|---|---|
| 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)
七、可複製的協作方法
- 先讓 AI 建立初版:快速完成接線表、程式架構、可能的除錯方向。
- 分段測試硬體:先測音檔、再測按鈕、再測超音波,最後才整合燈號。
- 量測而不猜測:以電表確認 BUSY 在播放時為 2.4V、停止時為 0V,再寫程式判斷。
- 記錄異常現象:例如 BUSY 讀值跳動、綠燈閃爍、超音波量到背景反射。
- 修正模型,而非只修補結果:這次不是單純改亮燈順序,而是重新定義「診斷訊號」與「互動狀態」應採並列處理。
- 以可運作版本收斂:將測試成功的程式、音檔編號、接線與測試設定完整保存。
沒有留言:
張貼留言