V1.0-07已經讓水井三寶故事機具備穩定的實體按鈕、AP網頁、播放中切歌、停止、音量記憶、BUSY逾時保護與LED狀態。下一個問題不再是「能不能播放」,而是展出之後能不能知道:
永久資料包含Magic、Schema版本與Checksum。重新開機時只有三項都正確才採用;第一次從V1.0-07升級時,程式會辨識舊格式,自動承接原本音量與開機次數,再轉成V1.0-08新結構。
展開/收合 V1.0-08完整程式
/*
* =====================================================================
* 水井三寶故事機|V1.0-08
* HUB 8735 Ultra + JQ6500 本機統計/Web管理後台/Flash延遲保存版
* =====================================================================
* AP:Shuijing-Story / shuijing8735
* 首頁:http://192.168.1.1/
* 管理:http://192.168.1.1/admin(PIN:8735,請於正式展出前自行修改)
* 保存:音量停止調整3秒後、一般統計累積60秒後,批次寫入Flash。
*
* 曲目:
* 001 白馬、002 烏龜、003 姻緣花、004 總故事、005 創作者
* 006 開機、007 準備完成、008 錯誤、009 停止提示
*
* 接線:
* GPIO8 Serial2 TX -> JQ6500 RX
* GPIO15 Serial2 RX <- JQ6500 TX
* GPIO4 <- JQ6500 BUSY(開機時自動校正待機電位)
* GPIO10 白馬、GPIO11 烏龜、GPIO12 姻緣花
* GPIO20 停止、GPIO7 音量+、GPIO6 音量-;按鈕另一端均接GND
*
* 電源:使用合格AC轉DC 5V電源;不可將市電直接接入開發板。
* =====================================================================
*/
#include <WiFi.h>
#include <FlashMemory.h>
#include <string.h>
char AP_SSID[] = "Shuijing-Story";
char AP_PASS[] = "shuijing8735";
char AP_CHANNEL[] = "1";
int AP_HIDDEN = 0;
const char ADMIN_PIN[] = "8735";
WiFiServer server(80);
#define BTN_HORSE 10
#define BTN_TURTLE 11
#define BTN_FLOWER 12
#define BTN_STOP 20
#define BTN_VOL_UP 7
#define BTN_VOL_DOWN 6
#define JQ_BUSY_PIN 4
#ifndef LED_G
#define LED_G 25
#endif
#ifndef LED_B
#define LED_B 26
#endif
#define LED_ON HIGH
#define LED_OFF LOW
#define TRACK_HORSE 1
#define TRACK_TURTLE 2
#define TRACK_FLOWER 3
#define TRACK_ALL 4
#define TRACK_CREATOR 5
#define TRACK_BOOT 6
#define TRACK_READY 7
#define TRACK_ERROR 8
#define TRACK_STOP 9
#define VOL_MIN 0
#define VOL_MAX 30
#define VOL_STEP 2
#define FIRMWARE_VERSION "V1.0-08"
// AmebaPro2官方FlashMemory保留區;只用第一個4KB sector。
#define SETTINGS_FLASH_BASE 0xFD0000
#define SETTINGS_FLASH_SIZE 0x1000
#define PERSIST_MAGIC 0x534A3038UL
#define PERSIST_SCHEMA 1UL
#define LEGACY_SETTINGS_MAGIC 0x53560700UL
#define LEGACY_SETTINGS_MASK 0xFFFFFF00UL
#define VOLUME_SAVE_DELAY_MS 3000
#define STATS_SAVE_DELAY_MS 60000UL
#define EVENT_CAPACITY 20
// JQ6500異常保護:未進入播放、BUSY卡住或故事異常過長都會自動復原。
#define AUDIO_BUSY_START_TIMEOUT_MS 1800
#define STORY_MAX_PLAY_MS 1800000UL
#define PROMPT_MAX_PLAY_MS 30000UL
// 009停止提示的BUSY保護時間
#define STOP_BUSY_START_TIMEOUT_MS 600
#define STOP_PROMPT_MAX_MS 8000
enum PlayerState {
STATE_AP_STARTING,
STATE_BOOT_PROMPT,
STATE_READY_PROMPT,
STATE_IDLE,
STATE_STORY_PLAYING,
STATE_PAUSE_WAIT,
STATE_RESET_WAIT,
STATE_STOP_PROMPT,
STATE_ERROR
};
// 所有永久資料一次寫入同一個結構,避免每個計數器各自擦寫Flash。
struct PersistentData {
uint32_t magic;
uint32_t schema;
uint32_t volume;
uint32_t bootCount;
uint32_t storyCount[5];
uint32_t buttonCommands;
uint32_t webCommands;
uint32_t adminCommands;
uint32_t stopCount;
uint32_t switchCount;
uint32_t volumeUpCount;
uint32_t volumeDownCount;
uint32_t jqRecoveries;
uint32_t checksum;
};
static_assert(sizeof(PersistentData) <= SETTINGS_FLASH_SIZE,
"PersistentData exceeds Flash buffer");
struct RuntimeEvent {
unsigned long seconds;
String source;
String action;
String result;
};
PersistentData stats;
RuntimeEvent recentEvents[EVENT_CAPACITY];
int eventHead = 0;
int eventCount = 0;
PlayerState playerState = STATE_AP_STARTING;
int currentVolume = 20;
int currentTrack = 0;
String currentTitle = "AP啟動中";
bool wifiReady = false;
bool busySeenPlaying = false;
int lastBusy = LOW;
int busyIdleLevel = LOW;
// 中斷完成後要播放的目標;0代表只停止、不播放下一首
int pendingTrack = 0;
String pendingTitle = "";
bool pendingIsStopPrompt = false;
unsigned long stateStartedAt = 0;
unsigned long lastStopAt = 0;
unsigned long lastApRetryAt = 0;
unsigned long lastErrorBlinkAt = 0;
unsigned long lastButtonDiagAt = 0;
bool errorBlink = false;
bool volumeDirty = false;
bool statsDirty = false;
bool errorRecoveryActive = false;
unsigned long volumeChangedAt = 0;
unsigned long statsChangedAt = 0;
unsigned long bootCount = 0;
unsigned long playerErrorCount = 0;
bool testMode = false;
String lastSource = "system";
String lastAction = "開機";
unsigned long lastActionAt = 0;
const int BUTTON_COUNT = 6;
const int buttonPins[BUTTON_COUNT] = {
BTN_HORSE, BTN_TURTLE, BTN_FLOWER,
BTN_STOP, BTN_VOL_UP, BTN_VOL_DOWN
};
int rawButton[BUTTON_COUNT] = {HIGH, HIGH, HIGH, HIGH, HIGH, HIGH};
int stableButton[BUTTON_COUNT] = {HIGH, HIGH, HIGH, HIGH, HIGH, HIGH};
unsigned long buttonChangedAt[BUTTON_COUNT] = {0, 0, 0, 0, 0, 0};
const char *stateName(PlayerState state)
{
switch (state) {
case STATE_AP_STARTING: return "AP_STARTING";
case STATE_BOOT_PROMPT: return "BOOT_PROMPT";
case STATE_READY_PROMPT: return "READY_PROMPT";
case STATE_IDLE: return "IDLE";
case STATE_STORY_PLAYING: return "STORY_PLAYING";
case STATE_PAUSE_WAIT: return "PAUSE_WAIT";
case STATE_RESET_WAIT: return "RESET_WAIT";
case STATE_STOP_PROMPT: return "STOP_PROMPT";
default: return "ERROR";
}
}
void enterState(PlayerState nextState, const String &title)
{
playerState = nextState;
currentTitle = title;
stateStartedAt = millis();
Serial.print("[STATE] ");
Serial.print(stateName(playerState));
Serial.print(" | ");
Serial.println(currentTitle);
}
// ---------------------------------------------------------------------
// JQ6500底層命令
// ---------------------------------------------------------------------
void jqSend(uint8_t command, const uint8_t *data, uint8_t dataLength)
{
Serial2.write(0x7E);
Serial2.write((uint8_t)(dataLength + 2));
Serial2.write(command);
for (uint8_t i = 0; i < dataLength; i++) {
Serial2.write(data[i]);
}
Serial2.write(0xEF);
Serial2.flush();
}
void jqSetVolume(int value, bool remember = true)
{
int newVolume = constrain(value, VOL_MIN, VOL_MAX);
bool changed = newVolume != currentVolume;
currentVolume = newVolume;
uint8_t data[1] = {(uint8_t)currentVolume};
jqSend(0x06, data, 1);
Serial.print("[VOLUME] ");
Serial.println(currentVolume);
// 延後合併寫入,避免連按音量鍵造成Flash過度擦寫。
if (remember && changed) {
volumeDirty = true;
volumeChangedAt = millis();
}
}
uint32_t persistentChecksum(const PersistentData &data)
{
const uint32_t *words = (const uint32_t *)&data;
int count = (sizeof(PersistentData) - sizeof(uint32_t)) / sizeof(uint32_t);
uint32_t hash = 2166136261UL;
for (int i = 0; i < count; i++) {
hash ^= words[i];
hash *= 16777619UL;
}
return hash;
}
void initializeStats()
{
memset(&stats, 0, sizeof(stats));
stats.magic = PERSIST_MAGIC;
stats.schema = PERSIST_SCHEMA;
stats.volume = 20;
}
void savePersistentData(const char *reason)
{
stats.magic = PERSIST_MAGIC;
stats.schema = PERSIST_SCHEMA;
stats.volume = currentVolume;
stats.bootCount = bootCount;
stats.jqRecoveries = playerErrorCount;
stats.checksum = persistentChecksum(stats);
// 先讀取整個sector,再只覆蓋本程式的結構,最後一次批次寫回。
FlashMemory.read(0);
memcpy(FlashMemory.buf, &stats, sizeof(stats));
FlashMemory.write(0);
volumeDirty = false;
statsDirty = false;
Serial.print("[FLASH] Batch saved: ");
Serial.println(reason);
}
void loadPersistentSettings()
{
FlashMemory.begin(SETTINGS_FLASH_BASE, SETTINGS_FLASH_SIZE);
FlashMemory.read(0);
PersistentData loaded;
memcpy(&loaded, FlashMemory.buf, sizeof(loaded));
bool valid = loaded.magic == PERSIST_MAGIC &&
loaded.schema == PERSIST_SCHEMA &&
loaded.checksum == persistentChecksum(loaded);
if (valid) {
stats = loaded;
currentVolume = constrain((int)stats.volume, VOL_MIN, VOL_MAX);
Serial.println("[FLASH] V1.0-08 statistics restored");
} else {
// 自動承接V1.0-07的前兩個word:音量與開機次數。
uint32_t legacySettings = 0;
uint32_t legacyBoots = 0;
memcpy(&legacySettings, FlashMemory.buf, sizeof(uint32_t));
memcpy(&legacyBoots, FlashMemory.buf + sizeof(uint32_t), sizeof(uint32_t));
initializeStats();
if ((legacySettings & LEGACY_SETTINGS_MASK) == LEGACY_SETTINGS_MAGIC) {
int oldVolume = legacySettings & 0xFF;
if (oldVolume >= VOL_MIN && oldVolume <= VOL_MAX) {
currentVolume = oldVolume;
stats.volume = oldVolume;
}
if (legacyBoots != 0xFFFFFFFFUL) stats.bootCount = legacyBoots;
Serial.println("[FLASH] Migrated V1.0-07 volume and boot count");
} else {
currentVolume = 20;
Serial.println("[FLASH] New statistics initialized");
}
}
bootCount = stats.bootCount + 1;
playerErrorCount = stats.jqRecoveries;
savePersistentData("boot count");
Serial.print("[FLASH] Volume = "); Serial.println(currentVolume);
Serial.print("[FLASH] Boot count = "); Serial.println(bootCount);
}
void markStatsDirty()
{
if (!statsDirty) statsChangedAt = millis();
statsDirty = true;
}
void addEvent(const String &source, const String &action, const String &result)
{
recentEvents[eventHead].seconds = millis() / 1000;
recentEvents[eventHead].source = source;
recentEvents[eventHead].action = action;
recentEvents[eventHead].result = result;
eventHead = (eventHead + 1) % EVENT_CAPACITY;
if (eventCount < EVENT_CAPACITY) eventCount++;
lastSource = source;
lastAction = action;
lastActionAt = millis() / 1000;
}
void recordCommand(const char *source, const String &action)
{
if (String(source) == "button") stats.buttonCommands++;
else if (String(source) == "admin") stats.adminCommands++;
else stats.webCommands++;
addEvent(source, action, "accepted");
markStatsDirty();
}
void updatePersistentSettings()
{
bool volumeDue = volumeDirty &&
millis() - volumeChangedAt >= VOLUME_SAVE_DELAY_MS;
bool statsDue = statsDirty &&
millis() - statsChangedAt >= STATS_SAVE_DELAY_MS;
// 自動寫入只在真正待機時執行,避免Flash操作影響播放中切歌與停止。
bool safeToWrite = playerState == STATE_IDLE &&
digitalRead(JQ_BUSY_PIN) == busyIdleLevel;
if ((volumeDue || statsDue) && safeToWrite) {
savePersistentData(volumeDue ? "volume delay" : "statistics delay");
}
}
void jqSetOneStopMode()
{
uint8_t mode[1] = {0x04};
jqSend(0x11, mode, 1);
Serial.println("[JQ] Mode ONE_STOP");
}
void jqPlay(int track)
{
uint8_t data[2] = {
(uint8_t)((track >> 8) & 0xFF),
(uint8_t)(track & 0xFF)
};
jqSend(0x03, data, 2);
currentTrack = track;
busySeenPlaying = false;
Serial.print("[JQ] Play track ");
Serial.println(track);
}
void playDirect(int track, const String &title, PlayerState playingState)
{
jqPlay(track);
enterState(playingState, title);
}
// ---------------------------------------------------------------------
// 非阻塞中斷流程
// Pause立即中斷 -> 等150ms -> Reset -> 等700ms -> 恢復模式與音量
// ---------------------------------------------------------------------
void beginInterrupt(int nextTrack, const String &nextTitle, bool isStopPrompt)
{
if (playerState == STATE_PAUSE_WAIT || playerState == STATE_RESET_WAIT) {
// 中斷進行中只更新最終目標,不重複送Pause/Reset。
pendingTrack = nextTrack;
pendingTitle = nextTitle;
pendingIsStopPrompt = isStopPrompt;
Serial.println("[QUEUE] Interrupt target updated");
return;
}
pendingTrack = nextTrack;
pendingTitle = nextTitle;
pendingIsStopPrompt = isStopPrompt;
jqSend(0x0E, NULL, 0);
currentTrack = 0;
busySeenPlaying = false;
enterState(STATE_PAUSE_WAIT, "正在中斷播放");
}
void finishInterrupt()
{
jqSetVolume(currentVolume, false);
jqSetOneStopMode();
if (pendingTrack > 0) {
int track = pendingTrack;
String title = pendingTitle;
bool stopPrompt = pendingIsStopPrompt;
pendingTrack = 0;
pendingTitle = "";
pendingIsStopPrompt = false;
PlayerState targetState = stopPrompt ? STATE_STOP_PROMPT : STATE_STORY_PLAYING;
if (errorRecoveryActive) {
targetState = STATE_ERROR;
errorRecoveryActive = false;
}
playDirect(track, title, targetState);
} else {
enterState(STATE_IDLE, "待機中");
}
}
void recoverPlayer(const char *reason)
{
playerErrorCount++;
stats.jqRecoveries = playerErrorCount;
addEvent("system", "JQ6500自動復原", reason);
markStatsDirty();
Serial.print("[WATCHDOG] JQ6500 recovery: ");
Serial.println(reason);
// 008只提示一次;若008本身也失敗,STATE_ERROR逾時後直接回待機。
errorRecoveryActive = true;
beginInterrupt(TRACK_ERROR, "播放器異常,正在復原", false);
}
void updatePlayerStateMachine()
{
if (playerState == STATE_PAUSE_WAIT && millis() - stateStartedAt >= 150) {
jqSend(0x0C, NULL, 0);
enterState(STATE_RESET_WAIT, "播放器重置中");
return;
}
if (playerState == STATE_RESET_WAIT && millis() - stateStartedAt >= 700) {
finishInterrupt();
return;
}
if (playerState == STATE_STOP_PROMPT) {
unsigned long elapsed = millis() - stateStartedAt;
// 009送出後沒有偵測到BUSY啟動:不讓STOP_PROMPT鎖住系統。
if (!busySeenPlaying && elapsed >= STOP_BUSY_START_TIMEOUT_MS) {
Serial.println("[WATCHDOG] Track 009 BUSY did not start; release to IDLE");
currentTrack = 0;
enterState(STATE_IDLE, "待機中");
return;
}
// 即使BUSY訊號卡住,最長8秒後仍會釋放控制權。
if (elapsed >= STOP_PROMPT_MAX_MS) {
Serial.println("[WATCHDOG] Track 009 timeout; release to IDLE");
currentTrack = 0;
busySeenPlaying = false;
enterState(STATE_IDLE, "待機中");
}
}
// 所有提示與故事共用的BUSY啟動逾時保護。
bool audioState = playerState == STATE_BOOT_PROMPT ||
playerState == STATE_READY_PROMPT ||
playerState == STATE_STORY_PLAYING ||
playerState == STATE_ERROR;
if (audioState && currentTrack > 0 && !busySeenPlaying &&
millis() - stateStartedAt >= AUDIO_BUSY_START_TIMEOUT_MS) {
if (playerState == STATE_ERROR) {
Serial.println("[WATCHDOG] Track 008 did not start; return to IDLE");
currentTrack = 0;
enterState(STATE_IDLE, "待機中(播放器提示失敗)");
} else {
recoverPlayer("BUSY did not start");
}
return;
}
if (audioState && busySeenPlaying) {
unsigned long limit = playerState == STATE_STORY_PLAYING ?
STORY_MAX_PLAY_MS : PROMPT_MAX_PLAY_MS;
if (millis() - stateStartedAt >= limit) {
if (playerState == STATE_ERROR) {
beginInterrupt(0, "", false);
} else {
recoverPlayer("BUSY playback timeout");
}
}
}
}
// ---------------------------------------------------------------------
// BUSY與自然播放完成
// ---------------------------------------------------------------------
void updateBusy()
{
int busy = digitalRead(JQ_BUSY_PIN);
if (busy != lastBusy) {
Serial.print("[BUSY] ");
Serial.print(lastBusy);
Serial.print(" -> ");
Serial.print(busy);
Serial.print(" | idle=");
Serial.println(busyIdleLevel);
}
// 開機播放前先記住待機電位;離開待機電位即代表開始播放。
if (currentTrack > 0 && busy != busyIdleLevel) {
busySeenPlaying = true;
}
bool returnedToIdle =
lastBusy != busyIdleLevel && busy == busyIdleLevel;
lastBusy = busy;
if (!returnedToIdle || !busySeenPlaying) {
return;
}
busySeenPlaying = false;
currentTrack = 0;
if (playerState == STATE_BOOT_PROMPT) {
delay(20);
playDirect(TRACK_READY, "系統準備完成", STATE_READY_PROMPT);
} else if (playerState == STATE_READY_PROMPT ||
playerState == STATE_STORY_PLAYING ||
playerState == STATE_STOP_PROMPT ||
playerState == STATE_ERROR) {
enterState(STATE_IDLE, "待機中");
}
}
// ---------------------------------------------------------------------
// 使用者命令
// ---------------------------------------------------------------------
void requestStory(int track, const String &title, const char *source)
{
// 即使狀態已被009 Watchdog釋放,只要BUSY仍在播放,仍先中斷再切歌。
bool hardwareBusy = digitalRead(JQ_BUSY_PIN) != busyIdleLevel;
if (playerState == STATE_AP_STARTING || playerState == STATE_ERROR) {
Serial.println("[COMMAND] Rejected: system not ready");
addEvent(source, title, "rejected");
return;
}
bool switching = playerState != STATE_IDLE || hardwareBusy;
recordCommand(source, title);
if (String(source) != "admin" &&
track >= TRACK_HORSE && track <= TRACK_CREATOR) {
stats.storyCount[track - 1]++;
}
if (switching && String(source) != "admin") stats.switchCount++;
markStatsDirty();
if (!switching) {
playDirect(track, title, STATE_STORY_PLAYING);
} else {
beginInterrupt(track, title, false);
}
}
void requestStop(const char *source)
{
if (playerState == STATE_AP_STARTING || playerState == STATE_ERROR) {
Serial.println("[STOP] Ignored in current state");
addEvent(source, "停止", "rejected");
return;
}
// 009播放期間再按停止:中斷009並回到待機,不再重播009。
if (playerState == STATE_STOP_PROMPT) {
Serial.println("[STOP] Stop prompt cancelled by user");
recordCommand(source, "取消停止提示");
if (String(source) != "admin") stats.stopCount++;
markStatsDirty();
beginInterrupt(0, "", false);
return;
}
// Watchdog已回到IDLE,但009可能仍在硬體播放;允許停止它。
if (playerState == STATE_IDLE) {
bool hardwareBusy = digitalRead(JQ_BUSY_PIN) != busyIdleLevel;
if (hardwareBusy) {
Serial.println("[STOP] Hardware still busy; stop without prompt");
recordCommand(source, "停止硬體播放");
if (String(source) != "admin") stats.stopCount++;
markStatsDirty();
beginInterrupt(0, "", false);
} else {
Serial.println("[STOP] Ignored: already idle");
}
return;
}
if (millis() - lastStopAt < 1000) {
Serial.println("[STOP] Duplicate ignored");
return;
}
lastStopAt = millis();
recordCommand(source, "停止播放");
if (String(source) != "admin") stats.stopCount++;
markStatsDirty();
beginInterrupt(TRACK_STOP, "停止提示", true);
}
void requestVolume(int delta, const char *source)
{
int before = currentVolume;
jqSetVolume(currentVolume + delta);
if (currentVolume == before) {
addEvent(source, delta > 0 ? "音量+" : "音量-", "limit");
return;
}
recordCommand(source, delta > 0 ? "音量+" : "音量-");
if (String(source) != "admin") {
if (delta > 0) stats.volumeUpCount++;
else stats.volumeDownCount++;
}
markStatsDirty();
}
bool buttonEdge(int index)
{
int reading = digitalRead(buttonPins[index]);
if (reading != rawButton[index]) {
rawButton[index] = reading;
buttonChangedAt[index] = millis();
}
if (millis() - buttonChangedAt[index] >= 35 &&
reading != stableButton[index]) {
stableButton[index] = reading;
if (stableButton[index] == LOW) {
Serial.print("[BUTTON] Pressed GPIO");
Serial.println(buttonPins[index]);
return true;
}
}
return false;
}
void handleButtons()
{
// 暫時保留展場診斷:每3秒顯示六個GPIO原始電位。
// 正常未按為1,按下期間應變成0。
if (testMode && millis() - lastButtonDiagAt >= 3000) {
lastButtonDiagAt = millis();
Serial.print("[BUTTON RAW] G10="); Serial.print(digitalRead(BTN_HORSE));
Serial.print(" G11="); Serial.print(digitalRead(BTN_TURTLE));
Serial.print(" G12="); Serial.print(digitalRead(BTN_FLOWER));
Serial.print(" G20="); Serial.print(digitalRead(BTN_STOP));
Serial.print(" G7="); Serial.print(digitalRead(BTN_VOL_UP));
Serial.print(" G6="); Serial.println(digitalRead(BTN_VOL_DOWN));
}
bool pressed[BUTTON_COUNT];
for (int i = 0; i < BUTTON_COUNT; i++) {
pressed[i] = buttonEdge(i);
}
if (pressed[0]) requestStory(TRACK_HORSE, "白馬故事", "button");
else if (pressed[1]) requestStory(TRACK_TURTLE, "烏龜故事", "button");
else if (pressed[2]) requestStory(TRACK_FLOWER, "姻緣花故事", "button");
else if (pressed[3]) requestStop("button");
else if (pressed[4]) requestVolume(VOL_STEP, "button");
else if (pressed[5]) requestVolume(-VOL_STEP, "button");
}
// ---------------------------------------------------------------------
// LED
// ---------------------------------------------------------------------
void updateLed()
{
digitalWrite(LED_G, LED_OFF);
digitalWrite(LED_B, LED_OFF);
if (playerState == STATE_ERROR) {
if (millis() - lastErrorBlinkAt >= 350) {
lastErrorBlinkAt = millis();
errorBlink = !errorBlink;
}
digitalWrite(LED_G, errorBlink ? LED_ON : LED_OFF);
digitalWrite(LED_B, errorBlink ? LED_OFF : LED_ON);
} else if (playerState == STATE_IDLE && !wifiReady) {
// AP尚未成功:綠燈維持待機可用,藍燈每500ms閃爍。
digitalWrite(LED_G, LED_ON);
digitalWrite(LED_B, ((millis() / 500) & 1) ? LED_ON : LED_OFF);
} else if (playerState == STATE_IDLE) {
digitalWrite(LED_G, LED_ON);
} else {
digitalWrite(LED_B, LED_ON);
}
}
// ---------------------------------------------------------------------
// HTTP
// ---------------------------------------------------------------------
void httpHeader(WiFiClient &client, const char *type, int code = 200)
{
if (code == 200) client.println("HTTP/1.1 200 OK");
else if (code == 403) client.println("HTTP/1.1 403 Forbidden");
else client.println("HTTP/1.1 404 Not Found");
client.print("Content-Type: "); client.println(type);
client.println("Cache-Control: no-store");
client.println("Connection: close");
client.println();
}
void printJsonString(WiFiClient &client, const String &value)
{
client.print('"');
for (unsigned int i = 0; i < value.length(); i++) {
char c = value[i];
if (c == '"' || c == '\\') { client.print('\\'); client.print(c); }
else if (c == '\n') client.print("\\n");
else if ((unsigned char)c >= 0x20) client.print(c);
}
client.print('"');
}
void sendStatus(WiFiClient &client)
{
httpHeader(client, "application/json; charset=utf-8");
client.print("{\"state\":\""); client.print(stateName(playerState));
client.print("\",\"title\":\""); client.print(currentTitle);
client.print("\",\"track\":"); client.print(currentTrack);
client.print(",\"volume\":"); client.print(currentVolume);
client.print(",\"busy\":");
client.print(digitalRead(JQ_BUSY_PIN) != busyIdleLevel ? "true" : "false");
client.print(",\"uptime\":"); client.print(millis() / 1000);
client.print(",\"wifi\":"); client.print(wifiReady ? "true" : "false");
client.print(",\"bootCount\":"); client.print(bootCount);
client.print(",\"errors\":"); client.print(playerErrorCount);
client.print(",\"firmware\":\""); client.print(FIRMWARE_VERSION); client.print("\"");
client.print(",\"testMode\":"); client.print(testMode ? "true" : "false");
client.println("}");
}
void sendAdminData(WiFiClient &client)
{
httpHeader(client, "application/json; charset=utf-8");
client.print("{\"firmware\":\""); client.print(FIRMWARE_VERSION);
client.print("\",\"state\":\""); client.print(stateName(playerState));
client.print("\",\"title\":"); printJsonString(client, currentTitle);
client.print(",\"track\":"); client.print(currentTrack);
client.print(",\"volume\":"); client.print(currentVolume);
client.print(",\"uptime\":"); client.print(millis() / 1000);
client.print(",\"bootCount\":"); client.print(bootCount);
client.print(",\"buttonCommands\":"); client.print(stats.buttonCommands);
client.print(",\"webCommands\":"); client.print(stats.webCommands);
client.print(",\"adminCommands\":"); client.print(stats.adminCommands);
client.print(",\"stopCount\":"); client.print(stats.stopCount);
client.print(",\"switchCount\":"); client.print(stats.switchCount);
client.print(",\"volumeUpCount\":"); client.print(stats.volumeUpCount);
client.print(",\"volumeDownCount\":"); client.print(stats.volumeDownCount);
client.print(",\"jqRecoveries\":"); client.print(playerErrorCount);
client.print(",\"testMode\":"); client.print(testMode ? "true" : "false");
client.print(",\"dirty\":"); client.print((statsDirty || volumeDirty) ? "true" : "false");
client.print(",\"lastSource\":"); printJsonString(client, lastSource);
client.print(",\"lastAction\":"); printJsonString(client, lastAction);
client.print(",\"lastActionAt\":"); client.print(lastActionAt);
client.print(",\"stories\":[");
for (int i = 0; i < 5; i++) {
if (i) client.print(',');
client.print(stats.storyCount[i]);
}
client.print("],\"events\":[");
for (int i = 0; i < eventCount; i++) {
if (i) client.print(',');
int index = (eventHead - 1 - i + EVENT_CAPACITY) % EVENT_CAPACITY;
client.print("{\"seconds\":"); client.print(recentEvents[index].seconds);
client.print(",\"source\":"); printJsonString(client, recentEvents[index].source);
client.print(",\"action\":"); printJsonString(client, recentEvents[index].action);
client.print(",\"result\":"); printJsonString(client, recentEvents[index].result);
client.print('}');
}
client.println("]}");
}
void sendPage(WiFiClient &client)
{
httpHeader(client, "text/html; charset=utf-8");
client.print(R"HTML(<!doctype html><html lang="zh-Hant"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>水井三寶故事機</title><style>
*{box-sizing:border-box}body{margin:0;background:linear-gradient(135deg,#e5f7f4,#fff2c9);font-family:Arial,"Noto Sans TC",sans-serif;color:#174349}.wrap{width:min(760px,94%);margin:22px auto}.panel{padding:24px;border:1px solid #b9d9d4;border-radius:24px;background:#ffffffed;box-shadow:0 10px 30px #17434922}h1{margin:0;color:#006b65;font-size:clamp(28px,7vw,43px)}.sub{margin:4px 0 18px;color:#668084}.status{padding:15px;border-radius:14px;background:#ebf8f6;font-size:18px;font-weight:bold}.dot{display:inline-block;width:13px;height:13px;margin-right:8px;border-radius:50%;background:#28a745}.stories{display:grid;grid-template-columns:repeat(3,1fr);gap:11px;margin-top:15px}.more,.controls{display:grid;grid-template-columns:repeat(2,1fr);gap:11px;margin-top:11px}.controls{grid-template-columns:1.2fr 1fr 1fr}.btn{min-height:72px;border:0;border-radius:15px;color:#fff;font-size:19px;font-weight:bold;cursor:pointer;box-shadow:0 4px 11px #0002}.horse{background:#ad6829}.turtle{background:#268149}.flower{background:#c14c78}.all{background:#7151a1}.creator{background:#96651f}.stop{background:#d43b3b}.vol{background:#187f9e}.meter{margin-top:17px;padding:13px;border-radius:12px;background:#f1f6f5}.small{text-align:center;color:#667b7e;font-size:13px}@media(max-width:560px){.stories,.more{grid-template-columns:1fr}.controls{grid-template-columns:1fr 1fr}.stop{grid-column:1/-1}.panel{padding:18px}}
</style></head><body><main class="wrap"><section class="panel">
<h1>水井三寶故事機</h1><p class="sub">V1.0-08|本機統計與管理後台版</p>
<div class="status"><span class="dot" id="dot"></span><span id="title">讀取中</span><br><small id="detail"></small></div>
<div class="stories"><button class="btn horse" onclick="cmd('/api/play/horse')">白馬故事</button><button class="btn turtle" onclick="cmd('/api/play/turtle')">烏龜故事</button><button class="btn flower" onclick="cmd('/api/play/flower')">姻緣花故事</button></div>
<div class="more"><button class="btn all" onclick="cmd('/api/play/all')">水井三寶總故事</button><button class="btn creator" onclick="cmd('/api/play/creator')">創作者資訊</button></div>
<div class="controls"><button class="btn stop" onclick="cmd('/api/stop')">停止播放</button><button class="btn vol" onclick="cmd('/api/volume/down')">音量 −</button><button class="btn vol" onclick="cmd('/api/volume/up')">音量 +</button></div>
<div class="meter">音量:<b id="vol">20</b>/30 運作時間:<span id="uptime">0</span>秒<br>開機次數:<span id="boots">0</span> 播放器復原:<span id="errors">0</span></div><p class="small">Wi-Fi:Shuijing-Story|<a href="/admin">管理後台</a></p>
</section></main><script>
const uiTitle=document.getElementById('title'),uiDetail=document.getElementById('detail'),uiVol=document.getElementById('vol'),uiUptime=document.getElementById('uptime'),uiDot=document.getElementById('dot'),uiBoots=document.getElementById('boots'),uiErrors=document.getElementById('errors');let locked=false,refreshing=false;async function cmd(u){if(locked)return;locked=true;try{await fetch(u,{cache:'no-store'});await refresh()}catch(e){}setTimeout(()=>locked=false,500)}
async function refresh(){if(refreshing)return;refreshing=true;try{let r=await fetch('/api/status',{cache:'no-store'}),s=await r.json();uiTitle.textContent=s.title;uiDetail.textContent=s.state+'|曲目 '+s.track+'|BUSY '+(s.busy?'1':'0')+'|'+s.firmware;uiVol.textContent=s.volume;uiUptime.textContent=s.uptime;uiBoots.textContent=s.bootCount;uiErrors.textContent=s.errors;uiDot.style.background=s.state==='ERROR'?'#dc3545':(s.state==='IDLE'?'#28a745':'#1680a1')}catch(e){uiTitle.textContent='連線中斷';uiDot.style.background='#dc3545'}finally{refreshing=false}}refresh();setInterval(refresh,3000);
</script></body></html>)HTML");
}
void sendAdminPage(WiFiClient &client)
{
httpHeader(client, "text/html; charset=utf-8");
client.print(R"ADMIN(<!doctype html><html lang="zh-Hant"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>故事機管理後台</title>
<style>*{box-sizing:border-box}body{margin:0;background:#edf5f3;color:#173f43;font-family:Arial,"Noto Sans TC",sans-serif}.wrap{width:min(980px,94%);margin:20px auto}.head,.card{background:#fff;border:1px solid #c9deda;border-radius:18px;padding:20px;margin-bottom:14px;box-shadow:0 5px 18px #173f4312}h1{margin:0;color:#006b65}.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}.metric{padding:14px;border-radius:12px;background:#eef8f6;text-align:center}.metric b{display:block;font-size:26px;color:#087d76}.stories,.tools{display:grid;grid-template-columns:repeat(3,1fr);gap:9px}.tools{grid-template-columns:repeat(4,1fr)}button,input{min-height:48px;border-radius:10px;border:1px solid #9bbfba;font-size:16px;padding:8px}button{border:0;background:#087d76;color:#fff;font-weight:bold;cursor:pointer}.danger{background:#c33b3b}.gold{background:#a46e13}.events{overflow-x:auto}table{width:100%;border-collapse:collapse}th,td{padding:9px;border-bottom:1px solid #dce8e6;text-align:left}.auth{display:flex;gap:8px}.auth input{flex:1}.msg{min-height:28px;color:#b33;font-weight:bold}@media(max-width:700px){.grid{grid-template-columns:repeat(2,1fr)}.stories,.tools{grid-template-columns:1fr 1fr}}</style></head><body><main class="wrap">
<section class="head"><h1>水井三寶故事機管理後台</h1><p>V1.0-08|本機統計+Flash延遲保存</p><div class="auth"><input id="pin" type="password" inputmode="numeric" placeholder="輸入管理PIN"><button onclick="loadData()">登入/更新</button></div><div id="msg" class="msg"></div></section>
<section class="card"><h2>即時狀態</h2><div class="grid"><div class="metric"><b id="state">-</b>狀態</div><div class="metric"><b id="track">0</b>曲目</div><div class="metric"><b id="volume">0</b>音量</div><div class="metric"><b id="uptime">0</b>運作秒數</div></div><p id="title">讀取中</p></section>
<section class="card"><h2>故事統計</h2><div class="grid"><div class="metric"><b id="s1">0</b>白馬</div><div class="metric"><b id="s2">0</b>烏龜</div><div class="metric"><b id="s3">0</b>姻緣花</div><div class="metric"><b id="s4">0</b>總故事</div><div class="metric"><b id="s5">0</b>創作者</div><div class="metric"><b id="buttons">0</b>實體按鈕</div><div class="metric"><b id="web">0</b>Web</div><div class="metric"><b id="admin">0</b>管理操作</div><div class="metric"><b id="switches">0</b>切歌</div></div></section>
<section class="card"><h2>曲目測試</h2><div class="stories"><button onclick="act('/api/admin/play?track=1')">001 白馬</button><button onclick="act('/api/admin/play?track=2')">002 烏龜</button><button onclick="act('/api/admin/play?track=3')">003 姻緣花</button><button onclick="act('/api/admin/play?track=4')">004 總故事</button><button onclick="act('/api/admin/play?track=5')">005 創作者</button><button onclick="act('/api/admin/play?track=6')">006 開機提示</button><button onclick="act('/api/admin/play?track=7')">007 準備完成</button><button onclick="act('/api/admin/play?track=8')">008 錯誤提示</button><button onclick="act('/api/admin/play?track=9')">009 停止提示</button></div></section>
<section class="card"><h2>管理工具</h2><div class="tools"><button class="danger" onclick="act('/api/admin/stop')">立即停止</button><button onclick="act('/api/admin/volume-down')">音量-</button><button onclick="act('/api/admin/volume-up')">音量+</button><button onclick="act('/api/admin/reset-player')">重設播放器</button><button onclick="act('/api/admin/save')">立即保存</button><button class="gold" onclick="act('/api/admin/toggle-test')">切換測試模式</button><button class="danger" onclick="clearStats()">清除統計</button></div><p>開機:<b id="boots">0</b>|停止:<b id="stops">0</b>|播放器復原:<b id="errors">0</b>|待保存:<b id="dirty">否</b>|測試模式:<b id="test">關</b></p></section>
<section class="card events"><h2>最近20筆事件(重新開機後清空)</h2><table><thead><tr><th>秒</th><th>來源</th><th>動作</th><th>結果</th></tr></thead><tbody id="events"></tbody></table></section>
<p><a href="/">回故事控制首頁</a></p></main><script>
const $=id=>document.getElementById(id);function pin(){return $('pin').value}async function api(path){let join=path.includes('?')?'&':'?';let r=await fetch(path+join+'pin='+encodeURIComponent(pin()),{cache:'no-store'});if(!r.ok)throw Error('PIN錯誤或命令失敗');return r}
async function loadData(){try{let r=await api('/api/admin/data'),s=await r.json();$('msg').textContent='';$('state').textContent=s.state;$('track').textContent=s.track;$('volume').textContent=s.volume;$('uptime').textContent=s.uptime;$('title').textContent=s.title;for(let i=0;i<5;i++)$('s'+(i+1)).textContent=s.stories[i];$('buttons').textContent=s.buttonCommands;$('web').textContent=s.webCommands;$('admin').textContent=s.adminCommands;$('switches').textContent=s.switchCount;$('boots').textContent=s.bootCount;$('stops').textContent=s.stopCount;$('errors').textContent=s.jqRecoveries;$('dirty').textContent=s.dirty?'是':'否';$('test').textContent=s.testMode?'開':'關';$('events').innerHTML=s.events.map(e=>`<tr><td>${e.seconds}</td><td>${e.source}</td><td>${e.action}</td><td>${e.result}</td></tr>`).join('')}catch(e){$('msg').textContent=e.message}}
async function act(path){try{await api(path);setTimeout(loadData,250)}catch(e){$('msg').textContent=e.message}}function clearStats(){if(confirm('確定清除故事與操作統計?開機次數與音量會保留。'))act('/api/admin/clear?confirm=YES')}setInterval(()=>{if(pin())loadData()},5000);
</script></body></html>)ADMIN");
}
int queryValue(const String &request, const String &key, int fallback)
{
int start = request.indexOf(key + "=");
if (start < 0) return fallback;
start += key.length() + 1;
int end = request.indexOf(' ', start);
return request.substring(start, end).toInt();
}
bool adminAuthorized(const String &request)
{
return request.indexOf(String("pin=") + ADMIN_PIN) >= 0;
}
String trackTitle(int track)
{
switch (track) {
case 1: return "白馬故事";
case 2: return "烏龜故事";
case 3: return "姻緣花故事";
case 4: return "水井三寶總故事";
case 5: return "創作者資訊";
case 6: return "開機提示測試";
case 7: return "準備完成測試";
case 8: return "錯誤提示測試";
case 9: return "停止提示測試";
default: return "";
}
}
void clearUsageStatistics()
{
memset(stats.storyCount, 0, sizeof(stats.storyCount));
stats.buttonCommands = 0;
stats.webCommands = 0;
stats.adminCommands = 0;
stats.stopCount = 0;
stats.switchCount = 0;
stats.volumeUpCount = 0;
stats.volumeDownCount = 0;
eventHead = 0;
eventCount = 0;
addEvent("admin", "清除使用統計", "success");
savePersistentData("admin clear statistics");
}
void handleWeb()
{
WiFiClient client = server.available();
if (!client) return;
client.setTimeout(100);
String req = client.readStringUntil('\n');
req.trim();
// 高頻status輪詢不輸出Serial,避免塞滿底層訊息佇列。
if (!req.startsWith("GET /api/status ") &&
!req.startsWith("GET /api/admin/data?")) {
Serial.print("[WEB] "); Serial.println(req);
}
unsigned long until = millis() + 60;
while (client.connected() && millis() < until) {
if (client.available()) {
String line = client.readStringUntil('\n');
if (line == "\r" || line.length() == 0) break;
}
}
bool adminApi = req.startsWith("GET /api/admin/");
if (adminApi && !adminAuthorized(req)) {
httpHeader(client, "application/json; charset=utf-8", 403);
client.println("{\"error\":\"invalid admin PIN\"}");
client.stop();
delay(10);
return;
}
if (req.startsWith("GET /api/play/horse ")) requestStory(TRACK_HORSE, "白馬故事", "web");
else if (req.startsWith("GET /api/play/turtle ")) requestStory(TRACK_TURTLE, "烏龜故事", "web");
else if (req.startsWith("GET /api/play/flower ")) requestStory(TRACK_FLOWER, "姻緣花故事", "web");
else if (req.startsWith("GET /api/play/all ")) requestStory(TRACK_ALL, "水井三寶總故事", "web");
else if (req.startsWith("GET /api/play/creator ")) requestStory(TRACK_CREATOR, "創作者資訊", "web");
else if (req.startsWith("GET /api/stop ")) requestStop("web");
else if (req.startsWith("GET /api/volume/up ")) requestVolume(VOL_STEP, "web");
else if (req.startsWith("GET /api/volume/down ")) requestVolume(-VOL_STEP, "web");
else if (req.startsWith("GET /api/volume/set?")) {
int target = constrain(queryValue(req, "value", currentVolume), VOL_MIN, VOL_MAX);
int delta = target - currentVolume;
if (delta != 0) requestVolume(delta, "web");
}
else if (req.startsWith("GET /api/admin/play?")) {
int track = queryValue(req, "track", 0);
String title = trackTitle(track);
if (track >= 1 && track <= 9) requestStory(track, title, "admin");
}
else if (req.startsWith("GET /api/admin/stop?")) requestStop("admin");
else if (req.startsWith("GET /api/admin/volume-up?")) requestVolume(VOL_STEP, "admin");
else if (req.startsWith("GET /api/admin/volume-down?")) requestVolume(-VOL_STEP, "admin");
else if (req.startsWith("GET /api/admin/reset-player?")) {
recordCommand("admin", "重設JQ6500");
beginInterrupt(0, "", false);
}
else if (req.startsWith("GET /api/admin/save?")) {
addEvent("admin", "立即保存", "success");
savePersistentData("admin manual save");
}
else if (req.startsWith("GET /api/admin/toggle-test?")) {
testMode = !testMode;
addEvent("admin", testMode ? "開啟測試模式" : "關閉測試模式", "success");
}
else if (req.startsWith("GET /api/admin/clear?") &&
req.indexOf("confirm=YES") >= 0) clearUsageStatistics();
if (adminApi) sendAdminData(client);
else if (req.startsWith("GET /api/")) sendStatus(client);
else if (req.startsWith("GET /admin ")) sendAdminPage(client);
else if (req.startsWith("GET / ")) sendPage(client);
else { httpHeader(client, "text/plain", 404); client.println("Not found"); }
client.stop();
// Ameba WiFi底層需要短暫時間釋放socket與RTOS queue資源。
delay(10);
}
// ---------------------------------------------------------------------
// AP:單次嘗試、立即返回。網路失敗不改變播放器狀態。
// ---------------------------------------------------------------------
bool tryStartAPOnce()
{
Serial.println("[AP] Non-blocking single attempt");
int status = WiFi.apbegin(AP_SSID, AP_PASS, AP_CHANNEL, AP_HIDDEN);
if (status != WL_CONNECTED) {
wifiReady = false;
Serial.print("[AP] Not ready, status=");
Serial.println(status);
Serial.println("[AP] Buttons and audio remain available");
return false;
}
wifiReady = true;
// AmebaPro2 WiFiServer預設為BLOCKING_MODE。
// 若沒有手機連線,server.available()會阻塞整個loop;
// 必須在begin前切換為NON_BLOCKING_MODE,確保按鈕與BUSY持續更新。
server.setNonBlockingMode();
server.begin();
Serial.print("[AP] SSID: "); Serial.println(AP_SSID);
Serial.print("[WEB] http://"); Serial.println(WiFi.localIP());
Serial.print("[ADMIN] http://"); Serial.print(WiFi.localIP()); Serial.println("/admin");
return true;
}
void setup()
{
Serial.begin(115200);
Serial2.begin(9600);
delay(1000);
for (int i = 0; i < BUTTON_COUNT; i++) {
pinMode(buttonPins[i], INPUT_PULLUP);
rawButton[i] = stableButton[i] = digitalRead(buttonPins[i]);
buttonChangedAt[i] = millis();
}
pinMode(JQ_BUSY_PIN, INPUT_PULLUP);
lastBusy = digitalRead(JQ_BUSY_PIN);
pinMode(LED_G, OUTPUT); pinMode(LED_B, OUTPUT);
Serial.println("================================================");
Serial.println("HUB 8735 Ultra + JQ6500 Story Player V1.0-08");
Serial.println("Local Statistics + Web Admin + Flash Batch Save");
Serial.println("================================================");
loadPersistentSettings();
addEvent("system", "系統開機", "success");
// AP只嘗試一次;無論成功或失敗,音訊與實體按鈕都必須啟動。
tryStartAPOnce();
jqSetVolume(currentVolume, false);
jqSetOneStopMode();
busyIdleLevel = digitalRead(JQ_BUSY_PIN);
lastBusy = busyIdleLevel;
Serial.print("[BUSY] Calibrated idle level = ");
Serial.println(busyIdleLevel);
playDirect(TRACK_BOOT, "系統啟動中", STATE_BOOT_PROMPT);
// 從本次嘗試完成後重新計算15秒重試週期。
lastApRetryAt = millis();
}
void loop()
{
// 最高優先:實體按鈕。即使AP完全失效仍持續掃描。
handleButtons();
// 第二、第三優先:JQ6500 BUSY與播放器狀態機。
updateBusy();
updatePlayerStateMachine();
// 最低優先:Web與AP恢復。
if (wifiReady) {
handleWeb();
} else if (millis() - lastApRetryAt >= 15000) {
lastApRetryAt = millis();
tryStartAPOnce();
}
updateLed();
updatePersistentSettings();
delay(3);
}
V1.0-08讓水井三寶故事機從「可以互動」再前進到「可以營運」。文化故事不只被播放,也開始留下匿名的使用趨勢;管理者不必接上Arduino IDE,就能用手機確認設備狀態、測試曲目與處理播放器。