2026年9月6日 星期日

從「燈號優先序」到「訊號並列」:樹藝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 的快速生成、人的現場觀察,以及一次次回到硬體與使用情境的驗證。

沒有留言:

張貼留言