ESP32 AP+STA 到 Django 雲端:水井三寶智慧互動展覽系統 Layer 4 V1.7 實作紀錄
從 JQ6500 故事播放、Edge Web、NVS Wi‑Fi 設定,到 Public DNS 修正與匿名事件雲端同步
實作版本:Layer 4 V1.7|Public DNS Fix
這次測試最重要的成果,不只是「ESP32 終於可以把資料送到雲端」,而是把一個展覽原型逐步整理成具有離線可操作、現場可診斷、網路可設定、事件可暫存、恢復連線可同步的 Edge+Cloud 架構。最後的 V1.7 已能讓 ESP32 同時維持本地 AP 與 Internet STA,透過 192.168.4.1 控制故事,並將匿名互動事件送往 Django 儀表板。
一、系統從「會播放」走向「真正可展覽」
整體可理解成四層:第二層負責 ESP32、ToF、三按鈕、JQ6500 與 RGB 的智慧互動;第三層加入 ESP32 內嵌 Edge Web,讓工作人員可用手機控制與診斷;第四層再把匿名事件送到 Django,形成展覽統計與設備健康資訊。
二、AP+STA 共存是展場架構的關鍵
ESP32 的 AP 並不是拿來取代 Internet,而是保留一條不依賴展場網路的本地控制通道。手機連上 Shuijing-Treasures 後,可直接進入 192.168.4.1;同一時間 STA 連上可上網的 Wi‑Fi,負責把事件送往 Django。即使外網失效,AP 與本地故事控制仍可工作。
V1.7 畫面可直接看到 AP 位址、STA 是否 ONLINE、STA IP、RSSI、DNS 與 Queue 大小。這些資訊對展場維護非常重要,因為「手機能控制」與「雲端能同步」其實是兩條不同的網路路徑。
三、測試中最關鍵的轉折:HTTP -1 的根因其實是 DNS
前期曾反覆看到 Cloud HTTP = -1。一開始容易懷疑 Django API、TLS、API Key 或 PythonAnywhere,但加入逐層診斷後,真正有決定性的紀錄是:
WiFi status = 3
STA IP = 192.168.1.119
Gateway = 192.168.1.1
DNS = 192.168.1.1
RSSI = -20
Resolving: shuijingtreasures.pythonanywhere.com
DNS result = -54
ERROR: DNS FAILED
因此 V1.7 不再只相信 DHCP 配發的 DNS,而是在 STA 連線成功後保留 DHCP IP,另外指定 Public DNS:
IPAddress DNS1(8, 8, 8, 8);
IPAddress DNS2(1, 1, 1, 1);
WiFi.setDNS(DNS1, DNS2);
修正後 Edge Web 已顯示 DNS:8.8.8.8,而 Cloud Test 也正式成功。
四、JQ6500:送出播放命令不等於真的播放
另一個重要經驗來自 JQ6500。早期程式只要送出 UART 命令就把狀態設成 PLAYING,但實測曾出現「程式顯示已播放、喇叭卻沒有聲音」。因此後續版本把 JQ6500 的 BUSY/播放確認與 Recovery 納入狀態判斷,不再把「已送命令」當成「播放成功」。
目前設備診斷畫面可看到 JQ BUSY、Start Confirmed、Retry、Fail,以及 Cloud OK / Fail。這使現場人員不用接電腦,也能從手機快速判斷問題是在音訊端還是網路端。
五、Web 狀態不能只靠重新整理
測試過程也發現:故事播完後,如果 Edge Web 還停留在 PLAYING,下一次操作會讓人誤以為系統延遲。這說明嵌入式 Web 不應只是「控制按鈕頁」,還應該是設備狀態的即時視窗。V1.7 延續前面的播放狀態管理,讓 PLAYING、IDLE、目前故事與 JQ 狀態有一致的資料來源。
六、匿名事件 Queue:網路斷線也不能讓展覽失憶
展場網路不可能永遠穩定,因此事件不是「有網路才記錄」。V1.7 先將 STORY_START、STORY_COMPLETE 等匿名事件排入 LittleFS Queue,再由 Cloud Sync 在適當時機送出。畫面中的 Queue: 14096 bytes 就是尚待同步的本地事件資料。
這個設計的價值是:互動優先、上雲其次。播放故事時避免 HTTPS 與 Wi‑Fi reconnect 搶資源;空閒後再同步,才符合實體展覽設備的可靠性需求。
七、從 ESP32 即時統計到 Django 儀表板
Edge Web 提供的是「這一台設備現在發生什麼事」,Django 則負責「今天整體發生多少互動」。兩者角色不同但互補。
ESP32 本地頁可看到白馬、烏龜、姻緣花、004、005、Web、實體按鈕、展區喚醒與完整播放等即時匿名統計。這些資料不需要辨識個人身分,而是記錄設備互動事件。
雲端儀表板已收到 Layer4‑V1.7 資料,可顯示故事啟動、完整播放、播放完成率、三寶與延伸故事、互動來源,以及 SHUIJING‑001 裝置健康。測試畫面中已有 9 次故事啟動、2 次完整播放,Edge Web 為主要互動來源,裝置狀態為 ONLINE。
八、這次除錯得到的工程經驗
| 現象 | 容易誤判 | 最後得到的經驗 |
|---|---|---|
| STA ONLINE | 以為 Internet 一定正常 | 還要分別驗證 Gateway、DNS、TLS、HTTP |
| Cloud HTTP = -1 | 先懷疑 Django | 本次真正根因是 DNS 解析失敗 |
| JQ TX 已送出 | 以為故事一定播放 | 必須用 BUSY/確認機制判斷實際播放 |
| Web 顯示 PLAYING | 以為狀態一定同步 | 播放完成後狀態也要回到 IDLE |
| Cloud 暫時離線 | 事件可能直接遺失 | 先進 LittleFS Queue,恢復網路後再同步 |
| 展場有 Internet | 認為不需要 AP | AP 是維護與現場控制的獨立生命線 |
九、V1.7 的設計原則
AP+STA 共存Public DNSNVS Wi‑Fi 設定JQ6500 RecoveryLittleFS QueueHTTPSDjango API匿名統計Edge Web
如果只把 ESP32 當成「播放 MP3 的控制板」,這套系統並不複雜;但當目標變成真正可長時間展出的作品,問題就會轉向可恢復性、可診斷性、離線能力與資料一致性。V1.7 的價值正是在這裡:不是增加更多炫目的功能,而是讓每一層失效時,其他層仍能盡量維持工作。
十、Layer 4 V1.7 完整 Arduino 程式
以下為本次實測版本的完整程式。實際部署前,請依自己的 Django 裝置資料設定 API Key,並避免在公開文章中放入正式金鑰或 Wi‑Fi 密碼。
/*
====================================================================
水井三寶智慧互動展覽系統
Shuijing Treasures Smart Interactive Exhibition System
Layer 4|Django Data Layer V1.7
Cloud Diagnostic + Local First / Cloud Later
--------------------------------------------------------------------
ESP32 NodeMCU-32S
+ VL53L0X ToF
+ 3 Buttons
+ JQ6500
+ WS2812
+ SoftAP
+ STA Internet
+ Embedded Web
+ NVS Wi-Fi Setting
+ LittleFS Offline Event Queue
+ Django Event API / Heartbeat
+ JQ6500 播放確認 / Recovery
+ Cloud DNS / TLS / HTTP 診斷
本版重點:
1. 播放故事優先,播放中完全不做 Django HTTPS。
2. READY / IDLE 都允許同步,只要最近 10 秒沒有本地操作。
3. /cloudtest 可直接測試 Django API。
4. postJsonToCloud() 會依序檢查:
WiFi status
STA IP
Gateway
DNS
hostByName()
TCP/TLS 443
HTTP begin
HTTP POST
response body
5. HTTPS connect/response timeout = 10 秒。
6. 每次 Queue 同步最多 1 筆,避免長時間阻塞。
7. Wi-Fi / Cloud 診斷每 30 秒一次。
AP:
SSID = Shuijing-Treasures
Password = 12345678
URL = http://192.168.4.1
Wi-Fi Setup:
http://192.168.4.1/wifi
Cloud Test:
http://192.168.4.1/cloudtest
Track:
001 白馬故事
002 烏龜故事
003 姻緣花故事
004 水井三寶總故事
005 創作者資訊
====================================================================
*/
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiAP.h>
#include <WebServer.h>
#include <Preferences.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <LittleFS.h>
#include <Wire.h>
#include <HardwareSerial.h>
#include <Adafruit_VL53L0X.h>
#include <Adafruit_NeoPixel.h>
// ==========================================================
// Version
// ==========================================================
const char* FIRMWARE_VERSION = "Layer4-V1.7";
// ==========================================================
// SoftAP
// ==========================================================
const char* AP_SSID = "Shuijing-Treasures";
const char* AP_PASSWORD = "12345678";
WebServer server(80);
Preferences prefs;
// ==========================================================
// STA Wi-Fi
// ==========================================================
String staSSID = "";
String staPassword = "";
bool restartScheduled = false;
unsigned long restartAt = 0;
unsigned long lastReconnectAttempt = 0;
// ==========================================================
// Public DNS
// ==========================================================
// 保留 DHCP IP,只覆寫 DNS server
IPAddress DNS1(8, 8, 8, 8);
IPAddress DNS2(1, 1, 1, 1);
// ==========================================================
// Django Cloud
// ==========================================================
const char* DEVICE_ID = "SHUIJING-001";
// 請改成 Django 中 SHUIJING-001 的 api_key
const char* DEVICE_KEY = "CHANGE-ME";
const char* CLOUD_HOST =
"shuijingtreasures.pythonanywhere.com";
const char* CLOUD_EVENT_URL =
"https://shuijingtreasures.pythonanywhere.com/api/exhibition/events/";
const char* CLOUD_HEARTBEAT_URL =
"https://shuijingtreasures.pythonanywhere.com/api/exhibition/heartbeat/";
const char* EVENT_QUEUE_FILE = "/events.queue";
const char* EVENT_TEMP_FILE = "/events.tmp";
// Cloud 策略
const unsigned long CLOUD_SYNC_INTERVAL_MS = 30000; // 30 sec
const unsigned long HEARTBEAT_INTERVAL_MS = 120000; // 120 sec
const unsigned long CLOUD_IDLE_GRACE_MS = 10000; // 10 sec
const uint8_t MAX_EVENTS_PER_SYNC = 1;
unsigned long lastCloudSync = 0;
unsigned long lastHeartbeat = 0;
unsigned long lastLocalActivity = 0;
unsigned long eventSequence = 0;
// ==========================================================
// GPIO
// ==========================================================
#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
// ==========================================================
// LED Zones
// ==========================================================
#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
// ==========================================================
// Parameters
// ==========================================================
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 = 180000;
const unsigned long TOF_INTERVAL_MS = 100;
const unsigned long PERSON_DWELL_MS = 1200;
const unsigned long ATTRACT_MS = 1500;
const int PERSON_DISTANCE_MM = 1200;
// 若實測播放時 BUSY=LOW,改成 false
const bool BUSY_ACTIVE_HIGH = true;
const bool USE_BUSY_CONFIRM = true;
const unsigned long JQ_START_CONFIRM_MS = 1000;
const unsigned long JQ_RETRY_CONFIRM_MS = 1500;
const unsigned long DIAG_INTERVAL_MS = 30000;
// ==========================================================
// Hardware
// ==========================================================
HardwareSerial JQ(2);
Adafruit_VL53L0X lox;
Adafruit_NeoPixel strip(
LED_COUNT,
LED_PIN,
NEO_GRB + NEO_KHZ800
);
// ==========================================================
// State
// ==========================================================
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,
STORY_ALL = 4,
STORY_CREATOR = 5
};
SystemState state = STATE_IDLE;
Story currentStory = STORY_NONE;
// ==========================================================
// Button
// ==========================================================
struct Button
{
uint8_t pin;
bool stableState;
bool lastReading;
unsigned long lastChange;
bool pressedEvent;
};
Button horseButton;
Button turtleButton;
Button flowerButton;
// ==========================================================
// Runtime
// ==========================================================
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;
bool jqStartConfirmed = false;
int lastDistanceMM = -1;
uint8_t currentVolume = 20;
// ==========================================================
// Stats
// ==========================================================
struct Statistics
{
unsigned long wakeCount = 0;
unsigned long horseCount = 0;
unsigned long turtleCount = 0;
unsigned long flowerCount = 0;
unsigned long totalCount = 0;
unsigned long creatorCount = 0;
unsigned long buttonCount = 0;
unsigned long webCount = 0;
unsigned long completedCount = 0;
unsigned long stopCount = 0;
unsigned long jqRetryCount = 0;
unsigned long jqFailCount = 0;
unsigned long cloudSuccessCount = 0;
unsigned long cloudFailCount = 0;
};
Statistics stats;
// ==========================================================
// Names
// ==========================================================
String 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";
}
String storyName(Story story)
{
switch (story)
{
case STORY_HORSE: return "白馬故事";
case STORY_TURTLE: return "烏龜故事";
case STORY_FLOWER: return "姻緣花故事";
case STORY_ALL: return "水井三寶總故事";
case STORY_CREATOR: return "創作者資訊";
default: return "待機";
}
}
String storyCode(Story story)
{
switch (story)
{
case STORY_HORSE: return "001";
case STORY_TURTLE: return "002";
case STORY_FLOWER: return "003";
case STORY_ALL: return "004";
case STORY_CREATOR: return "005";
default: return "";
}
}
// ==========================================================
// Local Activity
// ==========================================================
void markLocalActivity()
{
lastLocalActivity = millis();
}
// ==========================================================
// Buttons
// ==========================================================
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;
}
// ==========================================================
// JQ6500
// ==========================================================
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;
}
currentVolume = volume;
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);
}
bool waitJQStart(unsigned long timeoutMs)
{
unsigned long start = millis();
while (millis() - start < timeoutMs)
{
server.handleClient();
if (jqIsBusy())
{
Serial.println(
"JQ6500 PLAY CONFIRMED"
);
return true;
}
delay(10);
}
Serial.println(
"JQ6500 PLAY NOT CONFIRMED"
);
return false;
}
bool startJQTrackWithRecovery(uint16_t track)
{
Serial.println();
Serial.println(
"===== JQ6500 PLAY START ====="
);
Serial.print(
"Track = "
);
Serial.println(track);
Serial.println(
"[1] First PLAY"
);
jqPlayTrack(track);
delay(100);
if (!USE_BUSY_CONFIRM)
{
return true;
}
if (waitJQStart(JQ_START_CONFIRM_MS))
{
Serial.println(
"JQ6500 FIRST PLAY OK"
);
return true;
}
stats.jqRetryCount++;
Serial.println();
Serial.println(
"[2] JQ6500 RECOVERY"
);
jqStop();
delay(300);
Serial.println(
"Select Flash..."
);
jqSelectFlash();
delay(700);
Serial.print(
"Set Volume = "
);
Serial.println(
currentVolume
);
jqSetVolume(
currentVolume
);
delay(500);
Serial.println();
Serial.println(
"[3] Retry PLAY"
);
jqPlayTrack(track);
delay(100);
if (waitJQStart(JQ_RETRY_CONFIRM_MS))
{
Serial.println(
"JQ6500 RETRY PLAY OK"
);
return true;
}
stats.jqFailCount++;
Serial.println(
"ERROR: JQ6500 PLAY FAILED"
);
return false;
}
// ==========================================================
// LED
// ==========================================================
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;
static int direction = 1;
if (millis() - last < 80)
{
return;
}
last = millis();
b += direction;
if (b >= 14) direction = -1;
if (b <= 4) direction = 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;
static int direction = 1;
if (millis() - last < 60)
{
return;
}
last = millis();
b += direction;
if (b >= 35) direction = -1;
if (b <= 8) direction = 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++;
if (pos >= HORSE_COUNT)
{
pos = 0;
}
strip.show();
}
void ledTurtle()
{
static unsigned long last = 0;
static int b = 10;
static int direction = 1;
if (millis() - last < 70)
{
return;
}
last = millis();
b += direction;
if (b >= 80) direction = -1;
if (b <= 10) direction = 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 ledAllStory()
{
static unsigned long last = 0;
static int phase = 0;
if (millis() - last < 900)
{
return;
}
last = millis();
phase++;
if (phase > 2)
{
phase = 0;
}
strip.clear();
if (phase == 0)
{
setZone(
HORSE_START,
HORSE_COUNT,
strip.Color(
70,
30,
0
)
);
}
else if (phase == 1)
{
setZone(
TURTLE_START,
TURTLE_COUNT,
strip.Color(
0,
25,
70
)
);
}
else
{
setZone(
FLOWER_START,
FLOWER_COUNT,
strip.Color(
70,
5,
25
)
);
}
strip.show();
}
void ledCreator()
{
strip.clear();
uint32_t warm =
strip.Color(
45,
28,
10
);
setZone(
HORSE_START,
HORSE_COUNT,
warm
);
setZone(
TURTLE_START,
TURTLE_COUNT,
warm
);
setZone(
FLOWER_START,
FLOWER_COUNT,
warm
);
strip.show();
}
void updateStoryLED()
{
switch (currentStory)
{
case STORY_HORSE:
ledHorse();
break;
case STORY_TURTLE:
ledTurtle();
break;
case STORY_FLOWER:
ledFlower();
break;
case STORY_ALL:
ledAllStory();
break;
case STORY_CREATOR:
ledCreator();
break;
default:
ledReady();
break;
}
}
// ==========================================================
// ToF
// ==========================================================
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
)
{
if (!personDetected)
{
personDetected = true;
stats.wakeCount++;
}
}
}
else
{
personTiming = false;
personDetected = false;
}
}
// ==========================================================
// Event Sequence
// ==========================================================
void loadEventSequence()
{
prefs.begin(
"event-seq",
true
);
eventSequence =
prefs.getULong(
"seq",
0
);
prefs.end();
}
unsigned long nextEventSequence()
{
eventSequence++;
prefs.begin(
"event-seq",
false
);
prefs.putULong(
"seq",
eventSequence
);
prefs.end();
return eventSequence;
}
String makeEventUUID()
{
char buf[80];
snprintf(
buf,
sizeof(buf),
"%s-%010lu",
DEVICE_ID,
nextEventSequence()
);
return String(buf);
}
// ==========================================================
// Event Queue
// ==========================================================
String buildEventJson(
const String& eventType,
Story story,
const String& source,
unsigned long durationMs,
int completedValue
)
{
String json;
json.reserve(440);
json += "{";
json += "\"event_uuid\":\"";
json += makeEventUUID();
json += "\",";
json += "\"event_type\":\"";
json += eventType;
json += "\",";
json += "\"story\":\"";
json += storyCode(story);
json += "\",";
json += "\"source\":\"";
json += source;
json += "\",";
json += "\"duration_ms\":";
json += String(durationMs);
json += ",";
json += "\"completed\":";
if (completedValue < 0)
{
json += "null";
}
else
{
json +=
completedValue
? "true"
: "false";
}
json += ",";
json += "\"network_status\":\"";
json +=
WiFi.status() == WL_CONNECTED
? "online"
: "offline";
json += "\",";
json += "\"metadata\":{";
json += "\"firmware\":\"";
json += FIRMWARE_VERSION;
json += "\",";
json += "\"jq_confirmed\":";
json +=
jqStartConfirmed
? "true"
: "false";
json += "}";
json += "}";
return json;
}
bool appendQueuedEvent(
const String& json
)
{
File file =
LittleFS.open(
EVENT_QUEUE_FILE,
FILE_APPEND
);
if (!file)
{
Serial.println(
"ERROR: event queue open failed"
);
return false;
}
file.println(json);
file.close();
Serial.print(
"EVENT QUEUED: "
);
Serial.println(json);
return true;
}
void emitLocalEvent(
const String& eventType,
Story story,
const String& source,
unsigned long durationMs = 0,
int completedValue = -1
)
{
String json =
buildEventJson(
eventType,
story,
source,
durationMs,
completedValue
);
appendQueuedEvent(json);
}
size_t queueFileSize()
{
if (
!LittleFS.exists(
EVENT_QUEUE_FILE
)
)
{
return 0;
}
File file =
LittleFS.open(
EVENT_QUEUE_FILE,
FILE_READ
);
if (!file)
{
return 0;
}
size_t size =
file.size();
file.close();
return size;
}
// ==========================================================
// Cloud Diagnostic V1.6
// DNS -> single TLS/HTTPS connection -> POST
// ==========================================================
bool postJsonToCloud(
const char* url,
const String& body
)
{
Serial.println();
Serial.println(
"========== CLOUD POST =========="
);
// ------------------------------------------------------
// 1. STA status
// ------------------------------------------------------
Serial.print(
"WiFi status = "
);
Serial.println(
WiFi.status()
);
if (
WiFi.status() != WL_CONNECTED
)
{
Serial.println(
"ERROR: STA OFFLINE"
);
stats.cloudFailCount++;
return false;
}
Serial.print(
"STA IP = "
);
Serial.println(
WiFi.localIP()
);
Serial.print(
"Gateway = "
);
Serial.println(
WiFi.gatewayIP()
);
Serial.print(
"DNS = "
);
Serial.println(
WiFi.dnsIP()
);
Serial.print(
"RSSI = "
);
Serial.println(
WiFi.RSSI()
);
// ------------------------------------------------------
// 2. DNS
// ------------------------------------------------------
IPAddress serverIP;
Serial.print(
"Resolving: "
);
Serial.println(
CLOUD_HOST
);
int dnsResult =
WiFi.hostByName(
CLOUD_HOST,
serverIP
);
Serial.print(
"DNS result = "
);
Serial.println(
dnsResult
);
if (
dnsResult != 1
)
{
Serial.println(
"ERROR: DNS FAILED"
);
stats.cloudFailCount++;
return false;
}
Serial.print(
"Resolved IP = "
);
Serial.println(
serverIP
);
// ------------------------------------------------------
// 3. Heap before TLS
// ------------------------------------------------------
Serial.print(
"Free Heap before TLS = "
);
Serial.println(
ESP.getFreeHeap()
);
// ------------------------------------------------------
// 4. Single TLS client
// 不再先做額外 testClient.connect()
// ------------------------------------------------------
WiFiClientSecure client;
// 測試階段先略過 CA 驗證
client.setInsecure();
// 秒
client.setHandshakeTimeout(
20
);
// 毫秒
client.setTimeout(
15000
);
HTTPClient https;
https.setConnectTimeout(
15000
);
https.setTimeout(
15000
);
https.setReuse(
false
);
Serial.print(
"POST URL = "
);
Serial.println(
url
);
if (
!https.begin(
client,
url
)
)
{
Serial.println(
"ERROR: https.begin FAILED"
);
stats.cloudFailCount++;
return false;
}
// ------------------------------------------------------
// 5. Headers
// ------------------------------------------------------
https.addHeader(
"Content-Type",
"application/json"
);
https.addHeader(
"X-Device-ID",
DEVICE_ID
);
https.addHeader(
"X-Device-Key",
DEVICE_KEY
);
https.addHeader(
"Connection",
"close"
);
Serial.println(
"POST body:"
);
Serial.println(
body
);
// ------------------------------------------------------
// 6. POST
// ------------------------------------------------------
unsigned long postStart =
millis();
int code =
https.POST(
body
);
unsigned long elapsed =
millis() -
postStart;
Serial.print(
"Cloud HTTP = "
);
Serial.println(
code
);
Serial.print(
"Elapsed = "
);
Serial.print(
elapsed
);
Serial.println(
" ms"
);
// ------------------------------------------------------
// 7. Negative HTTPClient result
// ------------------------------------------------------
if (
code < 0
)
{
Serial.print(
"HTTP error = "
);
Serial.println(
HTTPClient::errorToString(
code
)
);
Serial.print(
"Free Heap after fail = "
);
Serial.println(
ESP.getFreeHeap()
);
char sslError[192] = {0};
int tlsError =
client.lastError(
sslError,
sizeof(sslError)
);
Serial.print(
"TLS last error = "
);
Serial.println(
tlsError
);
Serial.print(
"TLS message = "
);
Serial.println(
sslError
);
https.end();
client.stop();
stats.cloudFailCount++;
return false;
}
// ------------------------------------------------------
// 8. Server response
// ------------------------------------------------------
String response =
https.getString();
Serial.print(
"Response = "
);
Serial.println(
response
);
https.end();
client.stop();
Serial.print(
"Free Heap after POST = "
);
Serial.println(
ESP.getFreeHeap()
);
// ------------------------------------------------------
// 9. Result
// ------------------------------------------------------
if (
code >= 200
&&
code < 300
)
{
stats.cloudSuccessCount++;
Serial.println(
"CLOUD POST OK"
);
return true;
}
stats.cloudFailCount++;
Serial.println(
"CLOUD POST FAILED"
);
return false;
}
// ==========================================================
// Cloud Sync Rule
// ==========================================================
bool cloudSyncAllowed()
{
if (
state == STATE_PLAYING
)
{
return false;
}
if (
state == STATE_COOLDOWN
)
{
return false;
}
if (
state == STATE_ATTRACT
)
{
return false;
}
// READY / IDLE 都可同步
if (
state != STATE_IDLE
&&
state != STATE_READY
)
{
return false;
}
if (
WiFi.status() != WL_CONNECTED
)
{
return false;
}
if (
millis() - lastLocalActivity
< CLOUD_IDLE_GRACE_MS
)
{
return false;
}
return true;
}
void printCloudStatus()
{
Serial.print(
"CLOUD | "
);
if (
WiFi.status() != WL_CONNECTED
)
{
Serial.println(
"BLOCKED: STA OFFLINE"
);
return;
}
if (
state == STATE_PLAYING
)
{
Serial.println(
"BLOCKED: PLAYING"
);
return;
}
if (
state == STATE_COOLDOWN
)
{
Serial.println(
"BLOCKED: COOLDOWN"
);
return;
}
if (
state == STATE_ATTRACT
)
{
Serial.println(
"BLOCKED: ATTRACT"
);
return;
}
unsigned long elapsed =
millis() - lastLocalActivity;
if (
elapsed < CLOUD_IDLE_GRACE_MS
)
{
Serial.print(
"BLOCKED: LOCAL ACTIVITY, wait "
);
Serial.print(
(CLOUD_IDLE_GRACE_MS - elapsed)
/ 1000
);
Serial.println(
" sec"
);
return;
}
Serial.print(
"READY | Queue="
);
Serial.print(
queueFileSize()
);
Serial.println(
" bytes"
);
}
// ==========================================================
// Queue Sync
// ==========================================================
void copyRemainingLines(
File &input,
File &output
)
{
while (input.available())
{
String line =
input.readStringUntil('\n');
line.trim();
if (line.length() > 0)
{
output.println(line);
}
}
}
void syncQueuedEvents()
{
if (!cloudSyncAllowed())
{
return;
}
if (
!LittleFS.exists(
EVENT_QUEUE_FILE
)
)
{
return;
}
File input =
LittleFS.open(
EVENT_QUEUE_FILE,
FILE_READ
);
if (!input)
{
return;
}
LittleFS.remove(
EVENT_TEMP_FILE
);
File output =
LittleFS.open(
EVENT_TEMP_FILE,
FILE_WRITE
);
if (!output)
{
input.close();
return;
}
uint8_t sentThisPass = 0;
while (input.available())
{
String line =
input.readStringUntil('\n');
line.trim();
if (line.length() == 0)
{
continue;
}
if (
sentThisPass >=
MAX_EVENTS_PER_SYNC
)
{
output.println(line);
copyRemainingLines(
input,
output
);
break;
}
if (!cloudSyncAllowed())
{
output.println(line);
copyRemainingLines(
input,
output
);
break;
}
Serial.println(
"Cloud sync event..."
);
if (
postJsonToCloud(
CLOUD_EVENT_URL,
line
)
)
{
sentThisPass++;
Serial.println(
"Cloud sync OK"
);
}
else
{
Serial.println(
"Cloud sync FAILED"
);
output.println(line);
copyRemainingLines(
input,
output
);
break;
}
delay(20);
}
input.close();
output.close();
LittleFS.remove(
EVENT_QUEUE_FILE
);
File check =
LittleFS.open(
EVENT_TEMP_FILE,
FILE_READ
);
size_t remainingSize = 0;
if (check)
{
remainingSize =
check.size();
check.close();
}
if (remainingSize > 0)
{
LittleFS.rename(
EVENT_TEMP_FILE,
EVENT_QUEUE_FILE
);
}
else
{
LittleFS.remove(
EVENT_TEMP_FILE
);
}
}
// ==========================================================
// Heartbeat
// ==========================================================
void sendHeartbeat()
{
if (!cloudSyncAllowed())
{
return;
}
String body;
body.reserve(240);
body += "{";
body += "\"state\":\"";
body += stateName();
body += "\",";
body += "\"firmware\":\"";
body += FIRMWARE_VERSION;
body += "\",";
body += "\"rssi\":";
body += String(WiFi.RSSI());
body += "}";
Serial.println(
"Sending heartbeat..."
);
postJsonToCloud(
CLOUD_HEARTBEAT_URL,
body
);
}
// ==========================================================
// Start Story
// ==========================================================
void startStory(
Story story,
bool fromWeb
)
{
Serial.println();
Serial.println(
"========== START STORY =========="
);
Serial.print(
"story = "
);
Serial.println(
(int)story
);
Serial.print(
"source = "
);
Serial.println(
fromWeb
? "WEB"
: "BUTTON"
);
Serial.print(
"state = "
);
Serial.println(
stateName()
);
if (
state == STATE_PLAYING
||
state == STATE_COOLDOWN
)
{
Serial.println(
"START STORY rejected"
);
return;
}
markLocalActivity();
currentStory = story;
busySeenPlaying = false;
jqStartConfirmed = false;
clearButtonEvents();
uint16_t track = 0;
switch (story)
{
case STORY_HORSE:
track = 1;
stats.horseCount++;
Serial.println(
"PLAY 001 HORSE"
);
break;
case STORY_TURTLE:
track = 2;
stats.turtleCount++;
Serial.println(
"PLAY 002 TURTLE"
);
break;
case STORY_FLOWER:
track = 3;
stats.flowerCount++;
Serial.println(
"PLAY 003 FLOWER"
);
break;
case STORY_ALL:
track = 4;
stats.totalCount++;
Serial.println(
"PLAY 004 ALL"
);
break;
case STORY_CREATOR:
track = 5;
stats.creatorCount++;
Serial.println(
"PLAY 005 CREATOR"
);
break;
default:
Serial.println(
"INVALID STORY"
);
return;
}
jqStartConfirmed =
startJQTrackWithRecovery(
track
);
if (!jqStartConfirmed)
{
Serial.println(
"STORY ABORTED: JQ6500 NOT PLAYING"
);
emitLocalEvent(
"DEVICE_ERROR",
story,
"system",
0,
0
);
currentStory =
STORY_NONE;
state =
personDetected
? STATE_READY
: STATE_IDLE;
return;
}
busySeenPlaying = true;
if (fromWeb)
{
stats.webCount++;
}
else
{
stats.buttonCount++;
}
playStartTime =
millis();
state =
STATE_PLAYING;
emitLocalEvent(
"STORY_START",
story,
fromWeb
? "web"
: "button",
0,
-1
);
Serial.println(
"STATE -> PLAYING"
);
Serial.println(
"================================="
);
}
// ==========================================================
// Buttons
// ==========================================================
void handleButtons()
{
if (horseButton.pressedEvent)
{
startStory(
STORY_HORSE,
false
);
return;
}
if (turtleButton.pressedEvent)
{
startStory(
STORY_TURTLE,
false
);
return;
}
if (flowerButton.pressedEvent)
{
startStory(
STORY_FLOWER,
false
);
return;
}
}
// ==========================================================
// State Machine
// ==========================================================
void runStateMachine()
{
switch (state)
{
case STATE_IDLE:
ledIdle();
handleButtons();
if (
state != STATE_PLAYING
&&
personDetected
)
{
state =
STATE_ATTRACT;
stateStartTime =
millis();
markLocalActivity();
emitLocalEvent(
"EXHIBIT_WAKE",
STORY_NONE,
"system",
0,
-1
);
}
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()
)
{
Serial.println(
"PLAY COMPLETE"
);
unsigned long duration =
millis() - playStartTime;
stats.completedCount++;
markLocalActivity();
emitLocalEvent(
"STORY_COMPLETE",
currentStory,
"system",
duration,
1
);
cooldownStartTime =
millis();
state =
STATE_COOLDOWN;
}
if (
millis() - playStartTime
> MAX_PLAY_MS
)
{
Serial.println(
"PLAY TIMEOUT"
);
jqStop();
unsigned long duration =
millis() - playStartTime;
markLocalActivity();
emitLocalEvent(
"PLAY_TIMEOUT",
currentStory,
"system",
duration,
0
);
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;
}
}
// ==========================================================
// Wi-Fi NVS
// ==========================================================
void loadWiFiSettings()
{
prefs.begin(
"wifi-config",
true
);
staSSID =
prefs.getString(
"ssid",
""
);
staPassword =
prefs.getString(
"password",
""
);
prefs.end();
Serial.println();
Serial.println(
"========== NVS Wi-Fi =========="
);
Serial.print(
"SSID: "
);
if (staSSID.length())
{
Serial.println(
staSSID
);
}
else
{
Serial.println(
"(not configured)"
);
}
Serial.println(
"================================"
);
}
void connectSTA()
{
if (
staSSID.length() == 0
)
{
Serial.println(
"STA: no saved Wi-Fi"
);
return;
}
Serial.println();
Serial.println(
"========== STA CONNECT =========="
);
Serial.print(
"Connecting STA: "
);
Serial.println(
staSSID
);
WiFi.begin(
staSSID.c_str(),
staPassword.c_str()
);
unsigned long start =
millis();
while (
WiFi.status() != WL_CONNECTED
&&
millis() - start < 15000
)
{
Serial.print(".");
delay(500);
}
Serial.println();
if (
WiFi.status() != WL_CONNECTED
)
{
Serial.println(
"STA CONNECT FAILED"
);
Serial.println(
"SoftAP remains available."
);
return;
}
Serial.println(
"STA CONNECTED"
);
Serial.print(
"STA IP = "
);
Serial.println(
WiFi.localIP()
);
Serial.print(
"Gateway = "
);
Serial.println(
WiFi.gatewayIP()
);
Serial.print(
"DHCP DNS = "
);
Serial.println(
WiFi.dnsIP(0)
);
// ------------------------------------------------------
// Public DNS Fix
// ------------------------------------------------------
bool dnsOK =
WiFi.setDNS(
DNS1,
DNS2
);
Serial.print(
"Set Public DNS = "
);
Serial.println(
dnsOK
? "OK"
: "FAILED"
);
delay(200);
Serial.print(
"DNS1 = "
);
Serial.println(
WiFi.dnsIP(0)
);
Serial.print(
"DNS2 = "
);
Serial.println(
WiFi.dnsIP(1)
);
// ------------------------------------------------------
// DNS self-test
// ------------------------------------------------------
IPAddress testIP;
int dnsResult =
WiFi.hostByName(
CLOUD_HOST,
testIP
);
Serial.print(
"DNS self-test = "
);
Serial.println(
dnsResult
);
if (dnsResult == 1)
{
Serial.print(
"Resolved = "
);
Serial.println(
testIP
);
}
else
{
Serial.println(
"WARNING: DNS still failed."
);
}
Serial.println(
"================================="
);
}
void maintainSTA()
{
if (
staSSID.length() == 0
)
{
return;
}
if (
WiFi.status() != WL_CONNECTED
&&
millis() - lastReconnectAttempt > 15000
)
{
// 播放期間不主動重連
if (
state == STATE_PLAYING
)
{
return;
}
lastReconnectAttempt =
millis();
Serial.println(
"STA reconnect..."
);
WiFi.begin(
staSSID.c_str(),
staPassword.c_str()
);
unsigned long start =
millis();
while (
WiFi.status() != WL_CONNECTED
&&
millis() - start < 10000
)
{
server.handleClient();
delay(250);
}
if (
WiFi.status() == WL_CONNECTED
)
{
WiFi.setDNS(
DNS1,
DNS2
);
Serial.print(
"STA reconnected | IP="
);
Serial.print(
WiFi.localIP()
);
Serial.print(
" | DNS="
);
Serial.println(
WiFi.dnsIP(0)
);
}
else
{
Serial.println(
"STA reconnect failed"
);
}
}
}
// ==========================================================
// HTML
// ==========================================================
String htmlHeader(String title)
{
String s;
s.reserve(3800);
s +=
"<!DOCTYPE html>"
"<html lang='zh-TW'>"
"<head>"
"<meta charset='UTF-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>";
s +=
"<title>" +
title +
"</title>";
s +=
"<style>"
"body{margin:0;background:#f4f0e6;"
"font-family:Arial,'Microsoft JhengHei',sans-serif;color:#333}"
".wrap{max-width:760px;margin:auto;padding:16px}"
".hero{background:#654321;color:white;padding:20px;border-radius:16px}"
".card{background:white;margin-top:14px;padding:16px;border-radius:14px;"
"box-shadow:0 3px 12px #0002}"
".grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}"
".btn,button{display:block;width:100%;box-sizing:border-box;"
"text-align:center;border:0;border-radius:10px;padding:14px 8px;"
"font-size:16px;text-decoration:none;color:#222;background:#ddd}"
".horse{background:#f5c96b}"
".turtle{background:#81d4fa}"
".flower{background:#f48fb1}"
".all{background:#a5d6a7}"
".creator{background:#d7ccc8}"
".stop{background:#c62828;color:white}"
".cloud{background:#90caf9}"
"input{width:100%;box-sizing:border-box;padding:12px;"
"margin:6px 0 14px;border:1px solid #aaa;border-radius:8px}"
".ok{color:#2e7d32;font-weight:bold}"
".bad{color:#c62828;font-weight:bold}"
".note{font-size:13px;color:#666}"
"</style>"
"</head>"
"<body>"
"<div class='wrap'>"
"<div class='hero'>"
"<h1>🌳 水井三寶智慧互動展覽系統</h1>"
"<div>Layer 4 V1.7|Public DNS Fix</div>"
"</div>";
return s;
}
// ==========================================================
// Root
// ==========================================================
void handleRoot()
{
markLocalActivity();
String s =
htmlHeader(
"水井三寶"
);
s +=
"<div class='card'>"
"<h2>系統狀態</h2>";
s +=
"<p>State:<b>" +
stateName() +
"</b></p>";
s +=
"<p>Story:<b>" +
storyName(currentStory) +
"</b></p>";
s +=
"<p>ToF:" +
String(lastDistanceMM) +
" mm</p>";
s +=
"<p>AP:<b>192.168.4.1</b></p>";
s += "<p>STA:";
if (
WiFi.status() ==
WL_CONNECTED
)
{
s +=
"<span class='ok'>ONLINE</span> / ";
s +=
WiFi.localIP().toString();
s +=
" / RSSI ";
s +=
String(WiFi.RSSI());
s +=
" dBm";
}
else
{
s +=
"<span class='bad'>OFFLINE</span>";
}
s += "</p>";
s +=
"<p>DNS:<b>" +
WiFi.dnsIP(0).toString() +
"</b></p>";
s +=
"<p>Queue:<b>" +
String(queueFileSize()) +
" bytes</b></p>";
s +=
"<div class='grid'>"
"<a class='btn' href='http://192.168.4.1/wifi'>⚙ Wi-Fi 設定</a>"
"<a class='btn cloud' href='http://192.168.4.1/cloudtest'>☁ Cloud Test</a>"
"</div>";
s += "</div>";
// Stories
s +=
"<div class='card'>"
"<h2>🎵 故事控制</h2>"
"<div class='grid'>";
s +=
"<a class='btn horse' href='http://192.168.4.1/play?track=1'>"
"🐴 001 白馬故事</a>";
s +=
"<a class='btn turtle' href='http://192.168.4.1/play?track=2'>"
"🐢 002 烏龜故事</a>";
s +=
"<a class='btn flower' href='http://192.168.4.1/play?track=3'>"
"🌸 003 姻緣花故事</a>";
s +=
"<a class='btn all' href='http://192.168.4.1/play?track=4'>"
"🌳 004 水井三寶總故事</a>";
s +=
"<a class='btn creator' href='http://192.168.4.1/play?track=5'>"
"👨🎨 005 創作者資訊</a>";
s +=
"<a class='btn stop' href='http://192.168.4.1/stop'>"
"■ STOP</a>";
s +=
"</div>"
"</div>";
// Diagnostics
s +=
"<div class='card'>"
"<h2>🔧 設備診斷</h2>";
s +=
"<p>JQ BUSY:<b>";
s +=
jqIsBusy()
? "ACTIVE"
: "IDLE";
s += "</b></p>";
s +=
"<p>JQ Start Confirmed:<b>";
s +=
jqStartConfirmed
? "YES"
: "NO";
s += "</b></p>";
s +=
"<p>JQ Retry:" +
String(stats.jqRetryCount) +
"</p>";
s +=
"<p>JQ Fail:" +
String(stats.jqFailCount) +
"</p>";
s +=
"<p>Cloud OK:" +
String(stats.cloudSuccessCount) +
"</p>";
s +=
"<p>Cloud Fail:" +
String(stats.cloudFailCount) +
"</p>";
s += "</div>";
// Stats
s +=
"<div class='card'>"
"<h2>📊 即時匿名統計</h2>";
s += "<p>白馬:" + String(stats.horseCount) + "</p>";
s += "<p>烏龜:" + String(stats.turtleCount) + "</p>";
s += "<p>姻緣花:" + String(stats.flowerCount) + "</p>";
s += "<p>004:" + String(stats.totalCount) + "</p>";
s += "<p>005:" + String(stats.creatorCount) + "</p>";
s += "<p>Web:" + String(stats.webCount) + "</p>";
s += "<p>實體按鈕:" + String(stats.buttonCount) + "</p>";
s += "<p>展區喚醒:" + String(stats.wakeCount) + "</p>";
s += "<p>完整播放:" + String(stats.completedCount) + "</p>";
s += "</div>";
s +=
"</div>"
"</body>"
"</html>";
server.send(
200,
"text/html; charset=utf-8",
s
);
}
// ==========================================================
// Wi-Fi Page
// ==========================================================
void handleWiFiPage()
{
markLocalActivity();
String s =
htmlHeader(
"Wi-Fi 設定"
);
s +=
"<div class='card'>"
"<h2>Internet STA 設定</h2>";
s +=
"<p>本地 AP 永遠維持:"
"<b>Shuijing-Treasures</b> / 192.168.4.1</p>";
s +=
"<p>目前 SSID:<b>";
if (staSSID.length())
{
s += staSSID;
}
else
{
s += "尚未設定";
}
s += "</b></p>";
s +=
"<form method='GET' action='http://192.168.4.1/save'>";
s +=
"<label>Wi-Fi SSID</label>"
"<input name='ssid' maxlength='32' required value='";
s += staSSID;
s += "'>";
s +=
"<label>Wi-Fi Password</label>"
"<input type='password' name='password' maxlength='64'>";
s +=
"<button type='submit'>儲存並重新啟動</button>"
"</form>";
s +=
"<p><a class='btn' href='http://192.168.4.1/clearwifi'>"
"清除 STA 設定</a></p>";
s +=
"<p><a class='btn' href='http://192.168.4.1/'>"
"回控制頁</a></p>";
s +=
"</div>"
"</div>"
"</body>"
"</html>";
server.send(
200,
"text/html; charset=utf-8",
s
);
}
// ==========================================================
// Save Wi-Fi
// ==========================================================
void handleConfigSave()
{
markLocalActivity();
if (!server.hasArg("ssid"))
{
server.send(
400,
"text/plain",
"SSID missing"
);
return;
}
String newSSID =
server.arg("ssid");
String newPassword =
server.arg("password");
if (
newSSID.length() == 0
)
{
server.send(
400,
"text/plain",
"SSID empty"
);
return;
}
if (
newSSID == staSSID
&&
newPassword.length() == 0
)
{
newPassword =
staPassword;
}
prefs.begin(
"wifi-config",
false
);
prefs.putString(
"ssid",
newSSID
);
prefs.putString(
"password",
newPassword
);
prefs.end();
prefs.begin(
"wifi-config",
true
);
String verifySSID =
prefs.getString(
"ssid",
""
);
String verifyPassword =
prefs.getString(
"password",
""
);
prefs.end();
bool ok =
verifySSID == newSSID
&&
verifyPassword == newPassword;
if (!ok)
{
server.send(
500,
"text/plain",
"NVS ERROR"
);
return;
}
staSSID = verifySSID;
staPassword = verifyPassword;
String s =
htmlHeader(
"Wi-Fi 設定完成"
);
s +=
"<div class='card'>"
"<h2>✅ Wi-Fi 設定完成</h2>"
"<p>SSID:<b>";
s += staSSID;
s +=
"</b></p>"
"<p>設定已寫入 NVS。</p>"
"<p>ESP32 將於 5 秒後重新啟動。</p>"
"</div>"
"</div>"
"</body>"
"</html>";
server.send(
200,
"text/html; charset=utf-8",
s
);
restartScheduled = true;
restartAt = millis() + 5000;
}
// ==========================================================
// Clear Wi-Fi
// ==========================================================
void handleClearWiFi()
{
markLocalActivity();
prefs.begin(
"wifi-config",
false
);
prefs.clear();
prefs.end();
staSSID = "";
staPassword = "";
WiFi.disconnect();
server.send(
200,
"text/html; charset=utf-8",
"<html><meta charset='UTF-8'><body>"
"<h2>STA Wi-Fi 已清除</h2>"
"<p><a href='http://192.168.4.1/wifi'>重新設定</a></p>"
"</body></html>"
);
}
// ==========================================================
// Cloud Test
// ==========================================================
void handleCloudTest()
{
markLocalActivity();
String json =
buildEventJson(
"DEVICE_BOOT",
STORY_NONE,
"system",
0,
-1
);
Serial.println();
Serial.println(
"========== CLOUD TEST =========="
);
bool ok =
postJsonToCloud(
CLOUD_EVENT_URL,
json
);
String s =
htmlHeader(
"Cloud Test"
);
s +=
"<div class='card'>";
if (ok)
{
s +=
"<h2>✅ Cloud Test OK</h2>"
"<p>Django 已接受事件。</p>";
}
else
{
s +=
"<h2>❌ Cloud Test FAILED</h2>"
"<p>請查看 Serial Monitor 的 DNS / TLS / HTTP 診斷資訊。</p>";
}
s +=
"<p><a class='btn' href='http://192.168.4.1/'>回控制頁</a></p>"
"</div>"
"</div>"
"</body>"
"</html>";
server.send(
200,
"text/html; charset=utf-8",
s
);
}
// ==========================================================
// Web Play
// ==========================================================
void handlePlay()
{
markLocalActivity();
if (!server.hasArg("track"))
{
server.send(
400,
"text/plain",
"track missing"
);
return;
}
int track =
server.arg("track")
.toInt();
if (
track < 1
||
track > 5
)
{
server.send(
400,
"text/plain",
"invalid track"
);
return;
}
if (
state == STATE_PLAYING
||
state == STATE_COOLDOWN
)
{
server.sendHeader(
"Location",
"http://192.168.4.1/"
);
server.send(
303,
"text/plain",
""
);
return;
}
startStory(
(Story)track,
true
);
server.sendHeader(
"Location",
"http://192.168.4.1/"
);
server.send(
303,
"text/plain",
""
);
}
// ==========================================================
// Stop
// ==========================================================
void handleStop()
{
markLocalActivity();
unsigned long duration = 0;
if (
state == STATE_PLAYING
)
{
duration =
millis() - playStartTime;
}
jqStop();
stats.stopCount++;
emitLocalEvent(
"STOP",
currentStory,
"web",
duration,
0
);
currentStory =
STORY_NONE;
cooldownStartTime =
millis();
state =
STATE_COOLDOWN;
server.sendHeader(
"Location",
"http://192.168.4.1/"
);
server.send(
303,
"text/plain",
""
);
}
// ==========================================================
// Volume
// ==========================================================
void handleVolume()
{
markLocalActivity();
if (!server.hasArg("v"))
{
server.send(
400,
"text/plain",
"volume missing"
);
return;
}
int volume =
server.arg("v")
.toInt();
if (volume < 0) volume = 0;
if (volume > 30) volume = 30;
jqSetVolume(
(uint8_t)volume
);
server.sendHeader(
"Location",
"http://192.168.4.1/"
);
server.send(
303,
"text/plain",
""
);
}
// ==========================================================
// 404
// ==========================================================
void handleNotFound()
{
markLocalActivity();
server.send(
404,
"text/plain",
"Not Found"
);
}
// ==========================================================
// Web Setup
// ==========================================================
void setupWeb()
{
server.on(
"/",
HTTP_GET,
handleRoot
);
server.on(
"/wifi",
HTTP_GET,
handleWiFiPage
);
server.on(
"/save",
HTTP_ANY,
handleConfigSave
);
server.on(
"/clearwifi",
HTTP_GET,
handleClearWiFi
);
server.on(
"/cloudtest",
HTTP_GET,
handleCloudTest
);
server.on(
"/play",
HTTP_GET,
handlePlay
);
server.on(
"/stop",
HTTP_GET,
handleStop
);
server.on(
"/volume",
HTTP_GET,
handleVolume
);
server.onNotFound(
handleNotFound
);
server.begin();
}
// ==========================================================
// Setup
// ==========================================================
void setup()
{
Serial.begin(115200);
delay(1000);
Serial.println();
Serial.println(
"=============================================="
);
Serial.println(
"Shuijing Treasures Layer 4 V1.7"
);
Serial.println(
"CLOUD DIAGNOSTIC"
);
Serial.println(
"=============================================="
);
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(
currentVolume
);
Wire.begin(
TOF_SDA,
TOF_SCL
);
tofOK =
lox.begin();
Serial.println(
tofOK
? "VL53L0X OK"
: "VL53L0X ERROR"
);
if (
LittleFS.begin(true)
)
{
Serial.println(
"LittleFS OK"
);
}
else
{
Serial.println(
"LittleFS ERROR"
);
}
loadEventSequence();
Serial.println(
"Starting SoftAP..."
);
if (
!WiFi.softAP(
AP_SSID,
AP_PASSWORD
)
)
{
Serial.println(
"SoftAP FAILED"
);
while (true)
{
delay(1000);
}
}
Serial.print(
"AP IP = "
);
Serial.println(
WiFi.softAPIP()
);
loadWiFiSettings();
connectSTA();
setupWeb();
state =
STATE_IDLE;
markLocalActivity();
emitLocalEvent(
"DEVICE_BOOT",
STORY_NONE,
"system",
0,
-1
);
Serial.println();
Serial.println(
"SYSTEM READY"
);
Serial.println(
"Control: http://192.168.4.1"
);
Serial.println(
"Wi-Fi Setup: http://192.168.4.1/wifi"
);
Serial.println(
"Cloud Test: http://192.168.4.1/cloudtest"
);
Serial.print(
"Active DNS: "
);
Serial.println(
WiFi.dnsIP(0)
);
}
// ==========================================================
// Loop
// ==========================================================
void loop()
{
server.handleClient();
updateButtons();
updateToF();
runStateMachine();
maintainSTA();
if (
restartScheduled
&&
(long)(
millis() -
restartAt
) >= 0
)
{
server.stop();
delay(300);
ESP.restart();
}
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();
}
// Cloud sync
if (
millis() - lastCloudSync
>= CLOUD_SYNC_INTERVAL_MS
)
{
lastCloudSync =
millis();
if (cloudSyncAllowed())
{
syncQueuedEvents();
}
}
// Heartbeat
if (
millis() - lastHeartbeat
>= HEARTBEAT_INTERVAL_MS
)
{
lastHeartbeat =
millis();
if (cloudSyncAllowed())
{
sendHeartbeat();
}
}
// Diagnostics
static unsigned long
lastDiag = 0;
if (
millis() - lastDiag
>= DIAG_INTERVAL_MS
)
{
lastDiag =
millis();
Serial.print(
"DIAG | AP="
);
Serial.print(
WiFi.softAPgetStationNum()
);
Serial.print(
" | STA="
);
if (
WiFi.status() ==
WL_CONNECTED
)
{
Serial.print(
"ONLINE"
);
Serial.print(
" | IP="
);
Serial.print(
WiFi.localIP()
);
Serial.print(
" | RSSI="
);
Serial.print(
WiFi.RSSI()
);
}
else
{
Serial.print(
"OFFLINE"
);
}
Serial.print(
" | STATE="
);
Serial.print(
stateName()
);
Serial.print(
" | STORY="
);
Serial.print(
storyName(
currentStory
)
);
Serial.print(
" | QUEUE="
);
Serial.print(
queueFileSize()
);
Serial.println(
" bytes"
);
printCloudStatus();
}
}
結語:真正的 Edge AIoT 教材,是把失敗也留下來
這次從 AP、STA、JQ6500、Web 狀態、Queue,到最後找出 DNS 問題的過程,很適合作為物聯網與智慧生活課程案例。學生真正需要學的不只是「把程式燒進 ESP32」,而是如何從 Serial Log 與系統狀態逐層定位問題:硬體有沒有動?本地服務有沒有動?Wi‑Fi 有沒有連?DNS 能不能解析?HTTPS 能不能建立?API 有沒有接受?資料最後有沒有出現在儀表板?
當這條診斷鏈建立起來,「水井三寶」就不只是一件會說故事的地方工藝作品,而成為一套把地方文化、嵌入式系統、Edge Web、IoT 與雲端資料服務串在一起的智慧互動展覽實作。
沒有留言:
張貼留言