ESP32 × JQ6500:打造會感應、會發光、會說故事的「水井三寶」智慧互動展覽系統
一、Layer 2:從靜態展品走向智慧互動
智慧展覽的核心並不只是「放一塊 ESP32」,而是要思考: 當觀眾走近作品之後,系統如何感知?如何判斷?如何回應?
↓
VL53L0X ToF 距離感測
↓
ESP32 判斷互動狀態
↓
白馬/烏龜/姻緣花三按鈕選擇
↓
ESP32 透過 UART 命令 JQ6500 播放故事
↓
WS2812B 同步產生故事燈效
整套 Layer 2 因此包含四種重要能力: 感知、判斷、通訊與回饋。 其中 ESP32 與 JQ6500 之間的 UART 通訊,是整個「讓作品開口說故事」的關鍵。
二、水井三寶智慧互動層 V1.1 接線圖
V1.1 接線圖中特別重新整理了 JQ6500 UART 部分。 原因是 UART 最常見的錯誤,就是把 TX 接到 TX、RX 接到 RX。
三、ESP32 與 JQ6500 最重要的觀念:TX 要接 RX
ESP32 GPIO26(TX) → JQ6500 RX
ESP32 GPIO27(RX) ← JQ6500 TX
JQ6500 BUSY → ESP32 GPIO34
ESP32 GND ↔ JQ6500 GND
ESP32 JQ6500
──────────────────────────────────────
GPIO26 / TX ───────────────→ RX
GPIO27 / RX ←─────────────── TX
GPIO34 ←─────────────── BUSY
GND ──────────────── GND
UART 是 Universal Asynchronous Receiver/Transmitter, 中文通常稱為通用非同步收發傳輸。
負責把資料「送出去」,就像裝置的嘴巴。
負責把資料「收進來」,就像裝置的耳朵。
因此兩個設備互相溝通時,必須是:
TX → RX
RX ← TX
如果接成:
TX → TX
RX → RX
就像兩個人的「嘴巴接嘴巴、耳朵接耳朵」, 當然無法形成正常的資料傳遞。
四、為什麼程式寫 JQ_TX_PIN = 26?
Layer 2 V1.0 程式一開始定義:
#define JQ_TX_PIN 26
#define JQ_RX_PIN 27
#define JQ_BUSY_PIN 34
這裡的名稱很容易讓初學者混淆。
JQ_TX_PIN 所代表的 GPIO26,是
ESP32 用來把指令送給 JQ6500 的 TX 腳。
因此 GPIO26 必須接到:
ESP32 GPIO26 TX → JQ6500 RX
同樣地,GPIO27 是 ESP32 的接收腳:
ESP32 GPIO27 RX ← JQ6500 TX
五、ESP32 使用 UART2 與 JQ6500 通訊
程式建立一個硬體序列埠:
HardwareSerial JQ(2);
這代表我們使用 ESP32 的 Hardware UART2 專門負責和 JQ6500 溝通。
在 setup() 中:
JQ.begin(9600,SERIAL_8N1,JQ_RX_PIN,JQ_TX_PIN);
這一行的參數概念可以理解成:
JQ.begin(
傳輸速率,
資料格式,
ESP32 RX腳,
ESP32 TX腳
);
代入目前設定,就是:
JQ.begin(
9600,
SERIAL_8N1,
27,
26
);
| ESP32 | 功能 | JQ6500 |
|---|---|---|
| GPIO26 | ESP32 TX | RX |
| GPIO27 | ESP32 RX | TX |
| GPIO34 | 讀取播放狀態 | BUSY |
六、ESP32 如何命令 JQ6500 播放聲音?
ESP32 不會直接把「播放白馬故事」這句文字傳給 JQ6500, 而是依照 JQ6500 的通訊格式,送出一串位元組命令。
Layer 2 程式把 UART 傳送功能集中在:
void sendJQ(const uint8_t *data,size_t length){
JQ.write(data,length);
JQ.flush();
Serial.print("JQ TX: ");
for(size_t i=0;i<length;i++){
if(data[i]<0x10) Serial.print("0");
Serial.print(data[i],HEX);
Serial.print(" ");
}
Serial.println();
}
真正把資料傳到 JQ6500 的核心就是:
JQ.write(data,length);
這些資料會經由:
↓
GPIO26 TX
↓
JQ6500 RX
七、播放第 1、2、3 首故事
播放指定音軌的函式為:
void jqPlayTrack(uint16_t track){
uint8_t cmd[]={
0x7E,
0x04,
0x03,
(uint8_t)(track>>8),
(uint8_t)(track&0xFF),
0xEF
};
sendJQ(cmd,sizeof(cmd));
}
當白馬按鈕被按下:
jqPlayTrack(1);
烏龜則是:
jqPlayTrack(2);
姻緣花則是:
jqPlayTrack(3);
| 故事 | 按鈕 GPIO | 播放音軌 |
|---|---|---|
| 白馬故事 | GPIO32 | Track 1 |
| 烏龜故事 | GPIO33 | Track 2 |
| 姻緣花故事 | GPIO25 | Track 3 |
因此形成了一條完整的事件鏈:
↓
ESP32 GPIO 偵測 LOW
↓
程式判斷故事類別
↓
jqPlayTrack()
↓
UART 傳送命令
↓
JQ6500 播放 MP3
↓
喇叭播放水井三寶故事
八、只有 TX/RX 還不夠:BUSY 是重要的回饋訊號
如果 ESP32 只是把播放命令送出去,它並不知道 JQ6500 到底何時播放完成。
因此 Layer 2 又加入一條非常重要的訊號:
JQ6500 BUSY → ESP32 GPIO34
程式設定:
#define JQ_BUSY_PIN 34
初始化:
pinMode(JQ_BUSY_PIN,INPUT);
接著利用:
bool jqIsBusy(){
bool level=digitalRead(JQ_BUSY_PIN);
return BUSY_ACTIVE_HIGH ? level==HIGH : level==LOW;
}
讓 ESP32 知道 JQ6500 是否正在播放。
UART TX 是「ESP32 命令 JQ6500」,BUSY 則是「JQ6500 回報自己的工作狀態」。 因此系統不再只是單純的單向控制,而具有基本的回饋機制。
九、BUSY_ACTIVE_HIGH 為什麼要能調整?
程式中有:
// 若實測播放時 BUSY=LOW,改成 false
const bool BUSY_ACTIVE_HIGH=true;
不同版本的 JQ6500 模組或板子,其 BUSY 邏輯可能需要依實機確認。
如果實測發現「播放時 BUSY 是 LOW」,只要改成:
const bool BUSY_ACTIVE_HIGH=false;
這種設計比把 HIGH/LOW 直接寫死在各處更好, 因為只需要修改一個參數,就能適應不同硬體。
十、為什麼播放開始後要等待 300ms?
程式設定:
const unsigned long BUSY_GUARD_MS=300;
播放狀態中:
if(millis()-playStartTime<BUSY_GUARD_MS)break;
原因是 ESP32 執行速度非常快。 剛把 UART 播放命令送出去時,JQ6500 可能還需要一小段時間開啟音檔、開始解碼並改變 BUSY 狀態。
如果 ESP32 太早讀 BUSY,就可能把「尚未開始播放」誤認為「已經播放完畢」。
十一、播放結束後還有 2 秒 Cooldown
程式另外設定:
const unsigned long COOLDOWN_MS=2000;
播放完成後,不會立即重新接受下一次按鍵,而是進入
STATE_COOLDOWN。
這可以避免使用者手指還沒有離開按鈕, 就再度觸發同一段語音。
十二、JQ6500 只是 Layer 2 的其中一部分
Layer 2 其實整合了多種不同的通訊與控制方式:
| 模組 | ESP32 接腳 | 技術 | 用途 |
|---|---|---|---|
| JQ6500 | GPIO26、27 | UART | MP3 故事語音控制 |
| JQ6500 BUSY | GPIO34 | Digital Input | 確認語音播放狀態 |
| VL53L0X | GPIO21、22 | I²C | 觀眾距離感測 |
| 白馬按鈕 | GPIO32 | Digital Input | 選擇白馬故事 |
| 烏龜按鈕 | GPIO33 | Digital Input | 選擇烏龜故事 |
| 姻緣花按鈕 | GPIO25 | Digital Input | 選擇姻緣花故事 |
| WS2812B × 62 | GPIO13 | 單線數位控制 | 故事燈光效果 |
十三、62 顆 WS2812B 如何分區?
程式把 62 顆 LED 分成四個區域:
#define BASE_START 0
#define BASE_COUNT 24
#define HORSE_START 24
#define HORSE_COUNT 10
#define TURTLE_START 34
#define TURTLE_COUNT 12
#define FLOWER_START 46
#define FLOWER_COUNT 16
| 燈區 | LED 編號 | 數量 |
|---|---|---|
| 底座環境燈 | 0~23 | 24 |
| 白馬 | 24~33 | 10 |
| 烏龜 | 34~45 | 12 |
| 姻緣花 | 46~61 | 16 |
不同故事播放時,就可以只控制對應區域, 讓「聲音」和「燈光」建立關聯。
十四、真正讓智慧展品穩定運作的是「狀態機」
程式不是只寫:
if(按鈕按下){
播放MP3;
}
而是定義:
enum SystemState{
STATE_IDLE,
STATE_ATTRACT,
STATE_READY,
STATE_PLAYING,
STATE_COOLDOWN,
STATE_ERROR
};
↓
ATTRACT 吸引觀眾
↓
READY 等待選擇
↓
PLAYING 播放故事
↓
COOLDOWN 防止連續誤觸
↓
回到 READY 或 IDLE
這就是有限狀態機(Finite State Machine, FSM)的概念。
一旦系統從「單一 if 判斷」提升成狀態機, 程式就比較容易管理真正展場中可能出現的各種情況。
十五、Layer 2 V1.0 完整程式
以下為目前「水井三寶智慧互動展覽系統」第二層智慧互動層 Layer 2 V1.0 的完整 Arduino/ESP32 程式。
/*
水井三寶智慧互動展覽系統
第二層|智慧互動層 V1.0
ESP32 NodeMCU-32S + VL53L0X + 3 Buttons + JQ6500 + WS2812
*/
#include <Arduino.h>
#include <Wire.h>
#include <HardwareSerial.h>
#include <Adafruit_VL53L0X.h>
#include <Adafruit_NeoPixel.h>
#define JQ_TX_PIN 26
#define JQ_RX_PIN 27
#define JQ_BUSY_PIN 34
#define BTN_HORSE 32
#define BTN_TURTLE 33
#define BTN_FLOWER 25
#define TOF_SDA 21
#define TOF_SCL 22
#define LED_PIN 13
#define LED_COUNT 62
#define BASE_START 0
#define BASE_COUNT 24
#define HORSE_START 24
#define HORSE_COUNT 10
#define TURTLE_START 34
#define TURTLE_COUNT 12
#define FLOWER_START 46
#define FLOWER_COUNT 16
const unsigned long DEBOUNCE_MS=50;
const unsigned long COOLDOWN_MS=2000;
const unsigned long BUSY_GUARD_MS=300;
const unsigned long MAX_PLAY_MS=120000;
const int PERSON_DISTANCE_MM=1200;
const unsigned long PERSON_DWELL_MS=1200;
const unsigned long TOF_INTERVAL_MS=100;
const unsigned long ATTRACT_MS=1500;
// 若實測播放時 BUSY=LOW,改成 false
const bool BUSY_ACTIVE_HIGH=true;
HardwareSerial JQ(2);
Adafruit_VL53L0X lox;
Adafruit_NeoPixel strip(LED_COUNT,LED_PIN,NEO_GRB+NEO_KHZ800);
enum SystemState{
STATE_IDLE,
STATE_ATTRACT,
STATE_READY,
STATE_PLAYING,
STATE_COOLDOWN,
STATE_ERROR
};
enum Story{
STORY_NONE=0,
STORY_HORSE=1,
STORY_TURTLE=2,
STORY_FLOWER=3
};
SystemState state=STATE_IDLE;
Story currentStory=STORY_NONE;
struct Button{
uint8_t pin;
bool stableState;
bool lastReading;
unsigned long lastChange;
bool pressedEvent;
};
Button horseButton,turtleButton,flowerButton;
unsigned long stateStartTime=0;
unsigned long playStartTime=0;
unsigned long cooldownStartTime=0;
unsigned long lastToFTime=0;
unsigned long personStartTime=0;
bool personDetected=false;
bool personTiming=false;
bool tofOK=false;
bool busySeenPlaying=false;
int lastDistanceMM=-1;
const char* stateName(){
switch(state){
case STATE_IDLE:return "IDLE";
case STATE_ATTRACT:return "ATTRACT";
case STATE_READY:return "READY";
case STATE_PLAYING:return "PLAYING";
case STATE_COOLDOWN:return "COOLDOWN";
case STATE_ERROR:return "ERROR";
}
return "UNKNOWN";
}
void initButton(Button &btn,uint8_t pin){
btn.pin=pin;
pinMode(pin,INPUT_PULLUP);
btn.stableState=HIGH;
btn.lastReading=HIGH;
btn.lastChange=0;
btn.pressedEvent=false;
}
void updateButton(Button &btn){
bool reading=digitalRead(btn.pin);
if(reading!=btn.lastReading){
btn.lastReading=reading;
btn.lastChange=millis();
}
if(millis()-btn.lastChange>=DEBOUNCE_MS){
if(reading!=btn.stableState){
btn.stableState=reading;
if(btn.stableState==LOW)
btn.pressedEvent=true;
}
}
}
void updateButtons(){
updateButton(horseButton);
updateButton(turtleButton);
updateButton(flowerButton);
}
void clearButtonEvents(){
horseButton.pressedEvent=false;
turtleButton.pressedEvent=false;
flowerButton.pressedEvent=false;
}
bool allButtonsReleased(){
return horseButton.stableState==HIGH &&
turtleButton.stableState==HIGH &&
flowerButton.stableState==HIGH;
}
void sendJQ(const uint8_t *data,size_t length){
JQ.write(data,length);
JQ.flush();
Serial.print("JQ TX: ");
for(size_t i=0;i<length;i++){
if(data[i]<0x10)
Serial.print("0");
Serial.print(data[i],HEX);
Serial.print(" ");
}
Serial.println();
}
void jqSelectFlash(){
uint8_t cmd[]={0x7E,0x03,0x09,0x04,0xEF};
sendJQ(cmd,sizeof(cmd));
}
void jqSetVolume(uint8_t volume){
if(volume>30)
volume=30;
uint8_t cmd[]={0x7E,0x03,0x06,volume,0xEF};
sendJQ(cmd,sizeof(cmd));
}
void jqPlayTrack(uint16_t track){
uint8_t cmd[]={
0x7E,
0x04,
0x03,
(uint8_t)(track>>8),
(uint8_t)(track&0xFF),
0xEF
};
sendJQ(cmd,sizeof(cmd));
}
void jqStop(){
uint8_t cmd[]={0x7E,0x02,0x0E,0xEF};
sendJQ(cmd,sizeof(cmd));
}
bool jqIsBusy(){
bool level=digitalRead(JQ_BUSY_PIN);
return BUSY_ACTIVE_HIGH
? level==HIGH
: level==LOW;
}
void setZone(int start,int count,uint32_t color){
for(int i=0;i<count;i++)
strip.setPixelColor(start+i,color);
}
void ledIdle(){
static unsigned long last=0;
static int b=4,d=1;
if(millis()-last<80)
return;
last=millis();
b+=d;
if(b>=14)d=-1;
if(b<=4)d=1;
strip.clear();
setZone(BASE_START,BASE_COUNT,strip.Color(b,b/2,0));
strip.show();
}
void ledAttract(){
static unsigned long last=0;
static int b=8,d=1;
if(millis()-last<60)
return;
last=millis();
b+=d;
if(b>=35)d=-1;
if(b<=8)d=1;
strip.clear();
setZone(HORSE_START,HORSE_COUNT,strip.Color(b,b/2,0));
setZone(TURTLE_START,TURTLE_COUNT,strip.Color(0,b/2,b));
setZone(FLOWER_START,FLOWER_COUNT,strip.Color(b,0,b/3));
strip.show();
}
void ledReady(){
strip.clear();
setZone(HORSE_START,HORSE_COUNT,strip.Color(20,8,0));
setZone(TURTLE_START,TURTLE_COUNT,strip.Color(0,8,18));
setZone(FLOWER_START,FLOWER_COUNT,strip.Color(18,0,7));
strip.show();
}
void ledHorse(){
static unsigned long last=0;
static int pos=0;
if(millis()-last<80)
return;
last=millis();
strip.clear();
for(int i=0;i<HORSE_COUNT;i++){
int dist=(i-pos+HORSE_COUNT)%HORSE_COUNT;
if(dist==0)
strip.setPixelColor(
HORSE_START+i,
strip.Color(100,45,0)
);
else if(dist<=2)
strip.setPixelColor(
HORSE_START+i,
strip.Color(30,10,0)
);
}
pos=(pos+1)%HORSE_COUNT;
strip.show();
}
void ledTurtle(){
static unsigned long last=0;
static int b=10,d=1;
if(millis()-last<70)
return;
last=millis();
b+=d;
if(b>=80)d=-1;
if(b<=10)d=1;
strip.clear();
setZone(
TURTLE_START,
TURTLE_COUNT,
strip.Color(0,b/2,b)
);
strip.show();
}
void ledFlower(){
static unsigned long last=0;
static int count=1;
static bool growing=true;
if(millis()-last<120)
return;
last=millis();
strip.clear();
for(int i=0;i<count;i++)
strip.setPixelColor(
FLOWER_START+i,
strip.Color(80,5,25)
);
strip.show();
if(growing){
count++;
if(count>=FLOWER_COUNT){
count=FLOWER_COUNT;
growing=false;
}
}else{
count--;
if(count<=2){
count=2;
growing=true;
}
}
}
void updateStoryLED(){
switch(currentStory){
case STORY_HORSE:
ledHorse();
break;
case STORY_TURTLE:
ledTurtle();
break;
case STORY_FLOWER:
ledFlower();
break;
default:
ledReady();
break;
}
}
void updateToF(){
if(!tofOK)
return;
if(millis()-lastToFTime<TOF_INTERVAL_MS)
return;
lastToFTime=millis();
VL53L0X_RangingMeasurementData_t measure;
lox.rangingTest(&measure,false);
bool nowDetected=false;
if(measure.RangeStatus!=4){
lastDistanceMM=measure.RangeMilliMeter;
if(lastDistanceMM>50 &&
lastDistanceMM<=PERSON_DISTANCE_MM)
nowDetected=true;
}else{
lastDistanceMM=-1;
}
if(nowDetected){
if(!personTiming){
personTiming=true;
personStartTime=millis();
}
if(millis()-personStartTime>=PERSON_DWELL_MS)
personDetected=true;
}else{
personTiming=false;
personDetected=false;
}
}
void startStory(Story story){
if(state==STATE_PLAYING ||
state==STATE_COOLDOWN)
return;
currentStory=story;
busySeenPlaying=false;
clearButtonEvents();
if(story==STORY_HORSE)
jqPlayTrack(1);
else if(story==STORY_TURTLE)
jqPlayTrack(2);
else if(story==STORY_FLOWER)
jqPlayTrack(3);
playStartTime=millis();
state=STATE_PLAYING;
}
void handleButtons(){
if(horseButton.pressedEvent){
startStory(STORY_HORSE);
return;
}
if(turtleButton.pressedEvent){
startStory(STORY_TURTLE);
return;
}
if(flowerButton.pressedEvent){
startStory(STORY_FLOWER);
return;
}
}
void runStateMachine(){
switch(state){
case STATE_IDLE:
ledIdle();
handleButtons();
if(state!=STATE_PLAYING &&
personDetected){
state=STATE_ATTRACT;
stateStartTime=millis();
}
break;
case STATE_ATTRACT:
ledAttract();
handleButtons();
if(state!=STATE_PLAYING &&
millis()-stateStartTime>=ATTRACT_MS)
state=STATE_READY;
break;
case STATE_READY:
ledReady();
handleButtons();
if(state!=STATE_PLAYING &&
!personDetected)
state=STATE_IDLE;
break;
case STATE_PLAYING:
clearButtonEvents();
updateStoryLED();
if(millis()-playStartTime<BUSY_GUARD_MS)
break;
if(jqIsBusy())
busySeenPlaying=true;
if(busySeenPlaying &&
!jqIsBusy()){
cooldownStartTime=millis();
state=STATE_COOLDOWN;
}
if(millis()-playStartTime>MAX_PLAY_MS){
jqStop();
cooldownStartTime=millis();
state=STATE_COOLDOWN;
}
break;
case STATE_COOLDOWN:
clearButtonEvents();
ledReady();
if(millis()-cooldownStartTime>=COOLDOWN_MS &&
allButtonsReleased()){
currentStory=STORY_NONE;
state=personDetected
? STATE_READY
: STATE_IDLE;
}
break;
case STATE_ERROR:
strip.clear();
strip.show();
break;
}
}
void setup(){
Serial.begin(115200);
delay(1000);
Serial.println(
"\nShuijing Treasures Layer 2 V1.0"
);
initButton(horseButton,BTN_HORSE);
initButton(turtleButton,BTN_TURTLE);
initButton(flowerButton,BTN_FLOWER);
pinMode(JQ_BUSY_PIN,INPUT);
strip.begin();
strip.setBrightness(100);
strip.clear();
strip.show();
JQ.begin(
9600,
SERIAL_8N1,
JQ_RX_PIN,
JQ_TX_PIN
);
delay(3000);
jqSelectFlash();
delay(2500);
jqSetVolume(20);
Wire.begin(TOF_SDA,TOF_SCL);
tofOK=lox.begin();
Serial.println(
tofOK
? "VL53L0X OK"
: "VL53L0X ERROR -> button-only mode"
);
Serial.println("SYSTEM READY");
}
void loop(){
updateButtons();
updateToF();
runStateMachine();
if(JQ.available()){
Serial.print("JQ RX: ");
while(JQ.available()){
uint8_t b=JQ.read();
if(b<0x10)
Serial.print("0");
Serial.print(b,HEX);
Serial.print(" ");
}
Serial.println();
}
}
十六、從 Arduino 範例走向真正的智慧互動系統
如果只看單一元件,JQ6500 是 MP3 模組、VL53L0X 是距離感測器、 WS2812B 是 RGB LED,而 ESP32 只是一塊微控制器。
但當這些元件透過程式整合之後,就形成了一個完整的智慧互動系統:
↓
判斷 Decision
↓
通訊 Communication
↓
行動 Action
↓
回饋 Feedback
ToF 負責「感知」有人靠近; ESP32 負責「判斷」目前應該進入什麼狀態; UART 負責與 JQ6500「通訊」; JQ6500 與 WS2812B 負責產生聲音與燈光「行動」; BUSY 訊號再把 JQ6500 的狀態「回饋」給 ESP32。
十七、最值得拿來教學生的,不只是 GPIO 腳位
這個作品若只當成「接線實習」,學生可能只記住:
GPIO26
GPIO27
GPIO34
但真正應該學會的是:
2. 通訊協定:ESP32 如何把命令變成位元組送給 JQ6500?
3. Feedback:為什麼還需要 BUSY?
4. 非同步思維:為什麼送出命令後不能立刻假設硬體完成?
5. 狀態機:如何避免互動流程互相干擾?
6. 系統整合:如何讓感測器、聲音、燈光與地方故事形成完整體驗?
十八、結語:從兩條 UART 線,讓地方故事真正「活起來」
在整套水井三寶智慧互動展覽系統裡, ESP32 與 JQ6500 之間只有幾條線, 其中最不起眼的可能就是 TX 與 RX。
但正是:
ESP32 TX → JQ6500 RX
ESP32 RX ← JQ6500 TX
讓一塊微控制器可以命令另一個語音裝置工作。
再加入 BUSY、ToF、按鈕、RGB LED 與狀態機後, 原本只被觀看的地方工藝作品, 就開始具有感知環境、理解操作、播放故事與回應觀眾的能力。
這也是智慧生活科技很重要的核心精神:
從 ESP32 與 JQ6500 的 UART 通訊開始, 學生學到的不只是「如何播放 MP3」, 而是如何讓不同裝置彼此溝通, 最後形成一套真正可以放進場域中持續運作的智慧系統。

沒有留言:
張貼留言