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)

沒有留言:

張貼留言