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 的快速生成、人的現場觀察,以及一次次回到硬體與使用情境的驗證。

樹藝AI故事機:讓自然素材作品自己說故事

樹藝AI故事機:讓自然素材作品自己說故事

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

樹藝作品不只是靜態展示品。透過 Raspberry Pi Pico W、JQ6500 語音播放模組、超音波感測器與三個按鈕,我們可以製作一臺會主動迎賓、能讓參觀者自行選擇導覽內容、並在離場時說謝謝的「樹藝AI故事機」。

完成後的互動:作品開機會播放系統訊息;參觀者靠近時播放歡迎詞;按下按鈕可聆聽作品故事、使用材料或設計作者;真正離開後,故事機會播放「謝謝參觀」。

一、樹藝AI故事機的互動流程

1開機
播放系統訊息
2靠近
自動迎賓
3選擇
按下導覽按鈕
4聆聽
播放介紹內容
5離開
播放謝謝參觀

二、需要準備的硬體

元件用途說明
Raspberry Pi Pico W故事機控制核心執行 MicroPython 程式,讀取感測器與按鈕,發送播放命令。
JQ6500 語音播放模組播放 MP3插入 microSD 卡,連接喇叭後播放故事、材料與作者介紹。
HC-SR04 超音波感測器偵測參觀者靠近或離開量測距離,作為迎賓與致謝的觸發條件。
按鈕 × 3選擇導覽內容建議加上大字標籤:作品故事、使用材料、設計作者。
喇叭與 microSD 卡輸出語音與保存音檔建議使用 4Ω、3W 左右的小喇叭;microSD 卡格式化為 FAT32。
1kΩ 與 2kΩ 電阻保護 Pico W將 HC-SR04 的 ECHO 5V 訊號降壓為約 3.3V。

三、Pico W、JQ6500、超音波與按鈕接線

1. JQ6500 語音模組

Pico WJQ6500用途
VBUS 5V(實體 Pin 40)VCCJQ6500 供電
GND(實體 Pin 38 或任一 GND)GND共地,必接
GP0/UART0 TX(實體 Pin 1)RXPico W 傳送播放指令
不接TX第一版不讀取 JQ6500 狀態,避免 5V 訊號直接進入 Pico W。
SPK+、SPK-喇叭正、負極喇叭兩端直接接 JQ6500,不可接到 GND

2. HC-SR04 超音波感測器

Pico WHC-SR04用途
VBUS 5VVCC感測器供電
GNDGND共地
GP14(實體 Pin 19)TRIG發出超音波觸發訊號
GP15(實體 Pin 20)ECHO(經分壓)接收距離回傳訊號
重要安全提醒:HC-SR04 的 ECHO 是約 5V 輸出,不能直接接 Pico W 的 GP15。請用 1kΩ 與 2kΩ 電阻做分壓,保護 Pico W 的 3.3V GPIO。
HC-SR04 ECHO ── 1kΩ ──┬── Pico W GP15 │ 2kΩ │ GND

3. 三個導覽按鈕

功能Pico W GPIO播放音檔
作品故事GP10001.mp3
使用材料GP11003.mp3
設計作者GP12002.mp3

每一個按鈕的一端接對應 GPIO,另一端接 GND。程式使用 Pico W 的內建上拉電阻,因此不需再外接上拉電阻。

四、下載與使用 Thonny 開發 MicroPython

Thonny 是適合初學者的 Python 開發工具,可直接把程式寫入 Pico W,也能在 Shell 視窗查看感測器距離與除錯訊息。

  1. Thonny 官方網站下載並安裝 Windows 版本。
  2. 第一次使用 Pico W 時,到 Raspberry Pi MicroPython 官方文件下載 Pico W 專用的 UF2 韌體。
  3. 按住 Pico W 的 BOOTSEL 按鈕,再插入 USB 資料線;電腦會出現 RPI-RP2 磁碟。
  4. 將下載的 UF2 檔拖曳到 RPI-RP2,Pico W 會自動重新啟動。
  5. 開啟 Thonny,從右下角選擇「MicroPython (Raspberry Pi Pico)」,並選擇 Pico W 對應的 COM Port。
  6. 貼上本文程式,按 Ctrl + S,選擇儲存到「Raspberry Pi Pico」,檔名務必為 main.py
為什麼要存成 main.py?因為 Pico W 每次通電開機時,會自動執行內部儲存空間中的 main.py,故事機就不必再接著電腦才能運作。

五、完整 MicroPython 程式

本程式加入「七筆距離中位數濾波」與「連續五秒離場確認」,可減少 HC-SR04 因衣物、角度或背景反射造成的重複迎賓與致謝。

from machine import Pin, UART, time_pulse_us
import time

# 樹藝AI故事機:Pico W + JQ6500 + HC-SR04 + 三個按鈕

# JQ6500:Pico W GP0 接 JQ6500 RX
# 第一版只傳送播放命令,JQ6500 TX 可不接
jq6500 = UART(0, baudrate=9600, tx=Pin(0))

# HC-SR04 超音波感測器
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)     # 設計作者

# 距離與時間設定
ENTER_DISTANCE = 80        # 小於 80 cm:有人靠近
LEAVE_DISTANCE = 120       # 大於 120 cm:可能離開
WELCOME_WAIT_MS = 6000     # 歡迎詞播放後,等待 6 秒再開放按鈕
LEAVE_CONFIRM_MS = 5000    # 連續 5 秒在離場距離外,才判定離開
BUTTON_DEBOUNCE_MS = 500   # 按鍵防彈跳時間

# 最近 7 筆距離使用中位數濾波,降低錯誤反射的影響
FILTER_SIZE = 7
distance_buffer = []

# 系統狀態變數
person_is_here = False
last_button_time = 0
button_ready_time = 0
far_since = None


def play_file(number):
    """播放 JQ6500 microSD 卡內指定編號的 MP3。"""
    command = bytes([0x7E, 0x04, 0x03, 0x00, number, 0xEF])
    jq6500.write(command)
    print("播放音檔:", number)


def get_distance():
    """讀取 HC-SR04 距離;量測失敗時回傳 999。"""
    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():
    """保留最近 7 筆讀值,取中位數作為穩定距離。"""
    raw_distance = get_distance()
    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 time.ticks_diff(now, last_button_time) < BUTTON_DEBOUNCE_MS:
        return

    if button_story.value() == 0:
        print("選擇:作品故事")
        play_file(1)       # 001.mp3
        last_button_time = now

    elif button_material.value() == 0:
        print("選擇:使用材料")
        play_file(3)       # 003.mp3
        last_button_time = now

    elif button_author.value() == 0:
        print("選擇:設計作者")
        play_file(2)       # 002.mp3
        last_button_time = now


# 開機程序:004.mp3
print("樹藝AI故事機啟動中……")
time.sleep(2)
play_file(4)
print("系統已就緒,等待參觀者靠近……")


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

    # 開發測試時可取消下一行註解,觀察距離變化
    # print("原始:", raw_distance, "cm;濾波後:", distance, "cm")

    # 第一次偵測到有人靠近:播放 005.mp3 歡迎詞
    if distance < ENTER_DISTANCE and not person_is_here:
        print("參觀者靠近:", distance, "cm")
        play_file(5)

        person_is_here = True
        far_since = None
        button_ready_time = time.ticks_add(now, WELCOME_WAIT_MS)

    if person_is_here:
        # 歡迎詞播放一段時間後,開放三個按鈕
        if time.ticks_diff(now, button_ready_time) >= 0:
            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")
                play_file(6)       # 006.mp3:謝謝參觀

                person_is_here = False
                far_since = None
                time.sleep(1)

    time.sleep_ms(150)

六、microSD 音檔與語音文字稿

音檔功能觸發方式
001.mp3作品故事按下 GP10 按鈕
002.mp3設計作者按下 GP12 按鈕
003.mp3使用材料按下 GP11 按鈕
004.mp3系統開機訊息故事機通電啟動
005.mp3歡迎參觀樹藝工坊參觀者靠近
006.mp3謝謝參觀參觀者連續離開感測範圍五秒後

001.mp3作品故事|《貓頭鷹博士的月光工坊》

很久以前,森林邊有一位愛思考的貓頭鷹博士。他白天站在老樹上,仔細觀察落葉、果實和被風吹落的小樹枝;夜晚,便飛回月光照亮的小工坊,把自然送來的禮物做成有生命的作品。

有一天,森林裡的小動物問博士:「這些不起眼的木頭,真的能變成好看的東西嗎?」博士笑著說:「每一片木頭,都記得陽光、雨水和風吹過的故事。只要用心傾聽,就能找到它的新模樣。」

於是,博士選了一段圓圓的樹枝當身體,用深色木片做成翅膀,再加上兩顆明亮的大眼睛。慢慢地,一隻精神十足的貓頭鷹出現在大家面前。牠不是普通的貓頭鷹,而是守護森林故事的小博士。

從那天起,貓頭鷹博士提醒每一位來訪的人:不要輕易丟棄自然材料,也不要忘記身邊的老樹與土地。只要願意觀察、珍惜與創作,平凡的材料,也能長出新的故事。

002.mp3設計作者|樹藝工坊/李永謨工藝師

本作品由樹藝工坊的李永謨工藝師設計創作。李永謨老師自一九八九年起投入樹藝自然創作,經常從樹皮、樹葉、葉脈、果實與種子之中,細心尋找最適合創作的自然材料;有些植物甚至只能運用果實來呈現特色。

在李老師眼中,樹皮、葉子與果實雖然不完美,卻都有自己獨特的形狀、色彩、紋理與生命價值。透過巧妙的雙手與細膩的觀察,他將原本容易被忽略的自然素材,創造成栩栩如生的昆蟲、動物與花卉。

樹藝創作不只是手作藝術,更是一種尊重自然的方式。李老師讓看似無用的素材重獲新生,也帶領我們看見樹的另一面生命價值,並從創作中體悟珍惜、感恩與生命的真諦。

003.mp3使用材料

這件貓頭鷹博士作品,主要使用森林與校園中常見的自然素材創作。圓圓的身體是一顆海檬果;頭頂和翅膀使用三片玉米雞羽毛,展現貓頭鷹蓬鬆的羽毛感。

大大的眼睛與臉部輪廓,搭配麻棟、蓮蕉、羊蹄甲和青剛櫟等種子與果實,讓作品有了靈活的表情。作品底座使用一片大的樟木片,小樟木片做為裝飾;兩段樟木枝則像是貓頭鷹停棲的樹枝。最後,以一圈鋁線固定與連接各個部件。

這些材料原本都是自然掉落的果實、種子、羽毛與枝條。透過觀察、整理與創作,它們重新變成守護森林故事的貓頭鷹博士。提醒大家:海檬果不可食用,製作時請由老師或大人協助處理。

004.mp3系統開機訊息

您好,樹藝AI故事機已經啟動。歡迎來到樹藝工坊,邀請您一起認識自然素材所創造的美麗作品。

005.mp3歡迎參觀樹藝工坊

歡迎參觀樹藝工坊。您現在看到的是由自然果實、種子、樹皮、樹葉與羽毛創作而成的作品。請按下按鈕,選擇聆聽作品故事、使用材料或設計作者。讓我們一起發現大自然的另一種生命價值。

006.mp3謝謝參觀

謝謝您的參觀。願您帶著一雙善於觀察的眼睛,重新看見身邊的樹木、葉片、果實與種子。期待您再次來到樹藝工坊,聆聽更多大自然的故事。

七、程式測試技巧:先分開,再整合

故事機包含語音、感測與按鈕三種功能。開發時最有效的原則是:一次只測試一個元件。若一開始就執行完整程式,聲音、距離與按鈕同時出問題時,往往很難快速找出原因。

1. 建議的測試順序

階段要確認的事情成功判斷
第一階段:Pico WThonny 能連線、程式能儲存到 Pico W。Shell 出現 MPY: soft reboot 後,能看到 print() 訊息。
第二階段:JQ6500供電、喇叭、UART 接線與 microSD 音檔編號正確。可個別播放 001 至 006 等音檔。
第三階段:按鈕三個 GPIO 都能讀到按下與放開。按下不同按鈕,Shell 顯示正確的功能名稱。
第四階段:超音波量測距離與實際距離大致相符,且不會頻繁跳動。靠近時讀值變小,離開時讀值變大。
第五階段:整合迎賓、按鈕導覽、離場致謝能依序運作。每次靠近只迎賓一次;真正離開後才播放致謝詞。

2. JQ6500 單獨播放測試

先確認 JQ6500、喇叭與 microSD 音檔完全正常,再與感測器整合。將下列短程式存到 Pico W 執行,故事機會每四秒輪流播放開機、歡迎與致謝音檔。

from machine import Pin, UART
import time

jq6500 = UART(0, baudrate=9600, tx=Pin(0))

def play_file(number):
    command = bytes([0x7E, 0x04, 0x03, 0x00, number, 0xEF])
    jq6500.write(command)
    print("播放音檔:", number)

while True:
    play_file(4)   # 系統開機訊息
    time.sleep(4)
    play_file(5)   # 歡迎參觀
    time.sleep(4)
    play_file(6)   # 謝謝參觀
    time.sleep(4)
若沒有聲音,依序檢查:JQ6500 的 VCC 與 GND、喇叭是否接在 SPK+ 與 SPK-、Pico W GP0 是否接到 JQ6500 RX、microSD 是否為 FAT32,以及音檔是否為 MP3 格式與正確編號。

3. 三個按鈕的讀值測試

在還沒有接 JQ6500 前,也可先用 Shell 訊息確認按鈕接線。按鈕另一端必須接 GND;按下時,內建上拉的 GPIO 讀值會變為 0

from machine import Pin
import time

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)

while True:
    if button_story.value() == 0:
        print("按下:作品故事(GP10)")
        time.sleep_ms(300)

    elif button_material.value() == 0:
        print("按下:使用材料(GP11)")
        time.sleep_ms(300)

    elif button_author.value() == 0:
        print("按下:設計作者(GP12)")
        time.sleep_ms(300)

若按下按鈕沒有訊息,先不要修改主程式;請檢查按鈕兩腳是否接在正確的兩側、是否真的接到 GND,以及 GPIO 編號是否與實際接線一致。

4. 超音波感測器單獨測試

先讓 HC-SR04 只負責顯示距離。手持物品靠近感測器,距離數字應逐漸變小;移開後數字應逐漸變大。這一步可協助確認 TRIG、ECHO、分壓電阻與共地是否正確。

from machine import Pin, time_pulse_us
import time

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

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

while True:
    print("距離:", round(get_distance(), 1), "cm")
    time.sleep_ms(500)
超音波測試提醒:HC-SR04 對柔軟衣物、斜面、細小物件與遠方牆面,可能出現不同反射距離。正式程式使用七筆中位數濾波與五秒離場確認,就是為了避免單次量測跳動而重複播放語音。

5. 善用 Thonny Shell 找問題

  • 開發時保留 print(),例如印出「播放音檔」、「按鈕名稱」與「原始/濾波後距離」。
  • 測試成功後,再將頻繁執行的距離列印註解掉,避免 Shell 訊息太多、影響觀察。
  • 每次只修改一小段程式,儲存、執行、確認結果,再進行下一步;不要一次改接線與程式邏輯。
  • 出現錯誤時,先看 Shell 最後一行的錯誤類型與行號;例如 OSError 常與感測器量測或接線有關。
  • 看到 MPY: soft reboot 是 Thonny 重新執行 Pico W 程式的正常訊息,不是硬體故障。

6. 整合測試紀錄表

測試項目預期結果測試結果
通電開機播放 004.mp3 系統開機訊息□ 通過 □ 待調整
有人靠近只播放一次 005.mp3 歡迎詞□ 通過 □ 待調整
GP10 按鈕播放 001.mp3 作品故事□ 通過 □ 待調整
GP11 按鈕播放 003.mp3 使用材料□ 通過 □ 待調整
GP12 按鈕播放 002.mp3 設計作者□ 通過 □ 待調整
參觀者離開連續約五秒離場後播放 006.mp3□ 通過 □ 待調整

八、測試與展示前檢查

  1. 確認 Pico W、JQ6500、HC-SR04 的 GND 全部共地。
  2. 確認 HC-SR04 的 ECHO 已通過分壓電阻才連到 GP15。
  3. 確認 microSD 卡使用 FAT32,音檔以 001.mp3006.mp3 連續命名並依序放入。
  4. 通電後確認播放 004.mp3;靠近後確認只播放一次 005.mp3。
  5. 測試 GP10、GP11、GP12,確認分別播放作品故事、使用材料、設計作者。
  6. 離開感測範圍後,確認約五秒才播放 006.mp3,避免感測值偶爾跳動造成重複播放。
音檔管理建議:JQ6500 對 microSD 的檔案順序較敏感。建議先將記憶卡格式化為 FAT32,再依序放入 001.mp3006.mp3,中間不要缺號。日後若新增內容,可接續使用 007.mp3008.mp3,並同步修改程式中的播放編號。

2026年9月4日 星期五

雲端網路的核心概念:從智慧養殖到 AI 互動導覽

雲端網路的核心概念:從智慧養殖到 AI 互動導覽

當我們使用手機看影片、上傳照片、網路購物或遠端控制家電時,背後都離不開「雲端網路」。 雲端網路不只是把資料放到遠端伺服器,更是透過網路把人、設備、資料、服務與人工智慧連結起來, 讓系統能夠即時互動、彈性擴充與集中管理。

一、什麼是雲端網路?

雲端網路是指透過網際網路,使用遠端資料中心提供的運算、儲存、資料庫、應用程式及網路服務。 使用者不必自己購買大量伺服器或網路設備,只要透過電腦、手機、感測器或機器人連上網路, 就能使用雲端上的資源。

一句話理解:
雲端網路就是把「運算、儲存、網路與智慧服務」放到雲端,讓各地的使用者與設備可以隨時連線使用。

二、雲端網路的核心概念

  1. 虛擬化(Virtualization)
    將一台實體伺服器、硬碟或網路設備,切分成多個可獨立使用的虛擬資源,例如虛擬機器、虛擬硬碟與虛擬網路。
  2. 軟體定義網路(SDN, Software Defined Networking)
    將網路設備的管理與決策集中到軟體控制器。管理者可以用軟體調整資料傳輸路徑、頻寬與安全規則, 讓網路管理更有彈性。
  3. 網路功能虛擬化(NFV, Network Function Virtualization)
    原本要使用專用硬體才能完成的路由器、防火牆、負載平衡器等功能,改以軟體方式在一般伺服器上執行, 可降低成本,也更容易擴充。
  4. 基礎設施即代碼(IaC, Infrastructure as Code)
    用程式或設定檔建立伺服器、資料庫、網路與防火牆規則。這樣可以重複部署、保留版本紀錄, 並減少人工設定造成的錯誤。
  5. 彈性擴充與負載平衡
    當使用者增加時,雲端可以自動增加伺服器資源;當流量下降時,再減少資源以節省成本。 負載平衡則把大量使用者的請求分配到多台伺服器。
  6. 資安、身分驗證與資料保護
    雲端系統會利用帳號權限、防火牆、加密、VPN、網路分區與備份機制, 保護資料與設備,避免未授權使用者存取。

三、雲端網路在生活與產業上的應用

領域 應用例子 雲端網路的功能
智慧家庭 遠端控制冷氣、攝影機、門鎖與掃地機器人 讓家電與手機透過雲端交換資料與控制指令。
串流影音 YouTube、Netflix、線上直播與雲端遊戲 利用雲端伺服器、CDN 與負載平衡,提供大量使用者穩定服務。
線上學習 Google Classroom、Moodle、視訊課程 集中提供教材、作業、測驗、影片與學習紀錄。
電子商務 網路商店、行動支付、物流追蹤 整合商品、訂單、庫存、金流與配送資訊。
智慧製造 機台監控、品質檢測、預測維護 收集設備資料,透過雲端 AI 分析異常與預估故障。
智慧農漁業 水質、土壤、氣象、影像與設備控制 讓感測器即時上傳資料,並由手機或電腦遠端監控。

四、三個智慧生活與地方創生應用案例

案例一:水井村智慧養殖

在養殖現場,可利用溶氧、水溫、pH 值等感測器蒐集魚塭資料,再透過 HUB8735、 Wi-Fi、4G/5G 或 LoRa 網路傳送到雲端平台。養殖戶只要打開手機,就能即時查看水質狀況。

當溶氧過低或水溫異常時,雲端平台可以自動發出告警通知,並進一步連動打氣機或抽水設備。 若累積足夠資料,還能運用 AI 分析水質變化,協助養殖戶及早處理問題。

核心價值:讓養殖管理從「靠經驗巡池」走向「即時監測、遠端管理與智慧決策」。

案例二:水井三寶智慧互動導覽平台

水井村可將烏龜、白馬與姻緣花等地方文化故事,建置成雲端智慧導覽平台。 遊客掃描 QR Code、使用手機網頁或操作互動螢幕後,即可取得故事、照片、語音、影片與地圖資訊。

平台也可結合 AI 導覽 Agent。當遊客詢問「姻緣樹有多久歷史?」、 「下一個景點怎麼走?」或「水井三寶代表什麼意義?」時, AI Agent 可根據地方知識庫即時回答,成為不受時間限制的數位導覽員。

核心價值:讓地方文化能被數位保存、持續更新,並以互動方式傳遞給更多遊客與下一代。

案例三:Q-Robot AI 結合 AI Agent

Q-Robot 可結合麥克風、喇叭、螢幕、按鈕、燈光與感測器,成為能與人互動的智慧機器人。 當孩童、長者或遊客向 Q-Robot 說話或按下按鈕時,Q-Robot 可透過雲端網路連接 AI Agent 與知識庫, 理解問題後,再以語音、文字、燈光或動作回應。

例如,Q-Robot 可以扮演下列角色:

  • 地方文化小導遊:介紹水井三寶與村庄故事。
  • 兒童學習夥伴:帶領孩子完成程式設計、環保、食農與 AI 體驗任務。
  • 長者陪伴助手:以較慢的語速聊天、播放懷舊故事,提醒社區活動。
  • 活動任務引導員:依照參與者的回答與活動進度,安排下一個互動關卡。
核心價值:讓機器人不只是播放指令,而是能連接地方知識、理解問題並提供個人化互動的 AI 夥伴。

五、三個案例的比較

案例 主要對象 雲端網路的角色 AI 的角色
水井村智慧養殖 養殖戶 傳送感測資料、遠端控制設備 分析水質與異常預警
水井三寶智慧互動導覽 遊客、社區居民 提供故事、地圖、影音與使用紀錄 回答地方文化與導覽問題
Q-Robot AI Agent 兒童、長者、學習者 連接機器人、知識庫與後台服務 理解提問、規劃任務、產生個人化回應

六、結語

雲端網路的價值,不只是把資料存放在遠端,而是把現場設備、使用者、資料平台與 AI 智慧服務連接起來。 從水井村智慧養殖的水質監測,到水井三寶的文化導覽,再到 Q-Robot 的 AI 互動陪伴, 都說明了雲端網路可以成為地方創生、智慧教育與永續生活的重要基礎。

善用雲端網路,讓地方的故事、產業與學習,連結成更智慧、更永續的未來。