樹藝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 加密模式存在相容性問題。
MicroPython 社群在 2025 年仍有 Pico W AP 加上金鑰後無法正常啟用安全連線、甚至被辨識為 WEP 的回報;因此本次以實測可用性優先。參考:MicroPython Pico W AP mode runs only in open unsecured mode。
三、手機操作流程
- 開啟樹藝AI故事機電源,完成 LED 逐顆自我測試與系統開機語音。
- 在手機 Wi‑Fi 清單中選擇 TreeAIStoryBot。
- 本版為展示用開放式熱點,不需輸入密碼。
- 開啟 Safari、Chrome 等瀏覽器,輸入
http://192.168.4.1。 - 進入控制頁後,可播放音檔、套用感測門檻、切換模式,並使用「更新狀態」讀取最新資料。
四、網頁控制頁面說明
| 區塊 | 用途 |
|---|---|
| 即時狀態 | 顯示訪客狀態、目前音檔、超音波原始/濾波距離、JQ6500 BUSY 與感測器健康狀態。 |
| 播放音檔 | 可分別測試作品故事、使用材料、設計作者、開機訊息、歡迎詞與謝謝參觀。 |
| 感測門檻 | 可臨時設定靠近距離、離開距離,以及離開確認秒數。 |
| 維護工具 | 切換桌上測試/正式展示,或逐顆執行 LED 測試。 |
| 模式說明 | 直接保留兩種模式的距離條件,讓現場操作不必翻找程式碼。 |
五、測試模式與正式展示模式
| 模式 | 歡迎詞觸發 | 離開判定 | 適用場合 |
|---|---|---|---|
| 桌上測試 | 小於 10 cm | 大於 25 cm 並持續 5 秒 | 以手靠近感測器,快速測試功能 |
| 正式展示 | 小於 80 cm | 大於 120 cm 並持續 5 秒 | 展場參觀者靠近作品時自動導覽 |
六、測試過程中的兩個重要修正
- Wi‑Fi 加密相容性:先嘗試 AP 密碼,iPhone 顯示 WEP 且無法連線;改為開放式展示熱點後,手機可正常連線與控制。
- 網頁重播問題:初版網頁為了顯示即時狀態而自動刷新,但播放網址也被重送,造成音檔重播。修正後,控制命令送出會回到首頁,並改由「更新狀態」按鈕手動更新。
- 實體按鈕鎖定:按鈕必須先放開,才可再次觸發,避免長按、接點不穩或接線狀況造成連續播放。
- LED 與感測器:開機 LED 逐顆亮起後會明確關閉;HC‑SR04 保留 5 秒穩定時間,避免剛開機就誤亮紅燈。
七、開放式 AP 的使用原則
- 僅在可信任的展場或課堂現場短距離使用。
- 不要透過此網頁輸入個資、帳號、密碼或其他敏感內容。
- 展示結束後關閉故事機電源,熱點也會停止。
- 若未來要加入雲端服務或跨網路控制,改用既有 WPA2/WPA3 路由器或手機熱點。
八、程式碼
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) |
