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)
|