2026年8月24日 星期一

從故事播放器走向可管理的展場系統

水井三寶故事機|V1.0-08

從故事播放器走向可管理的展場系統

HUB 8735 Ultra+JQ6500本機統計、Web管理後台與Flash延遲保存實作紀錄

實機測試完成:故事播放、六個實體按鈕、手機Web、管理後台、PIN驗證、統計、事件紀錄、Flash保存、LED與JQ6500控制全部正常。
5類故事人氣統計
3來源按鈕/Web/管理
20筆最近操作事件
1次Flash批次寫入

一、為什麼需要V1.0-08?

V1.0-07已經讓水井三寶故事機具備穩定的實體按鈕、AP網頁、播放中切歌、停止、音量記憶、BUSY逾時保護與LED狀態。下一個問題不再是「能不能播放」,而是展出之後能不能知道:

  • 哪個故事最受歡迎?
  • 參觀者偏好使用實體按鈕還是手機?
  • 系統曾經停止、切歌或自動復原幾次?
  • 管理者能否不用重新燒錄,就完成曲目測試與播放器重設?

因此V1.0-08的核心是:讓設備可以被觀察、被管理,同時不犧牲V1.0-07已驗證的穩定性。

二、系統架構

實體按鈕
最高優先
非阻塞狀態機
JQ6500
本機統計
RAM累計
待機時批次寫入
Flash
手機控制首頁PIN管理後台同一個非阻塞
WiFiServer

三、操作與接線維持不變

GPIO/介面設備或按鈕功能
GPIO8/Serial2 TXJQ6500 RX傳送播放、停止、音量與重設命令
GPIO15/Serial2 RXJQ6500 TX保留播放器回傳通道
GPIO4JQ6500 BUSY判斷播放開始及完成
GPIO10白馬按鈕,另一端接GND001.mp3
GPIO11烏龜按鈕,另一端接GND002.mp3
GPIO12姻緣花按鈕,另一端接GND003.mp3
GPIO20停止按鈕,另一端接GND中斷並播放009提示
GPIO7/GPIO6音量+/音量-,另一端接GND每次調整2級
LED_G/LED_B板載綠/藍LEDHIGH點亮、LOW熄滅
供電仍是穩定運作的前提:請使用合格的AC轉DC 5V電源。先前使用電腦USB供電曾造成重置與006開機語音反覆播放;市電不可直接接入開發板。

四、故事與系統提示音

曲目內容是否列入故事人氣
001白馬故事民眾按鈕或Web操作時計入
002烏龜故事民眾按鈕或Web操作時計入
003姻緣花故事民眾按鈕或Web操作時計入
004水井三寶總故事Web操作時計入
005創作者資訊Web操作時計入
006開機提示不列入
007準備完成不列入
008錯誤提示記入播放器復原
009停止提示記入停止次數
統計不灌水:管理後台測試001~009時,只增加「管理操作」次數,不會增加民眾故事人氣與切歌統計。

五、手機首頁與管理後台

頁面網址用途
故事控制首頁http://192.168.1.1/民眾播放故事、停止及調整音量
管理後台http://192.168.1.1/admin統計、事件、曲目測試及維護
  1. 手機連線AP:Shuijing-Story
  2. 輸入Wi-Fi密碼:shuijing8735
  3. 一般操作開啟首頁;維護人員開啟/admin
  4. 管理命令輸入PIN:8735

正式展出前可修改:

const char ADMIN_PIN[] = "8735";
管理PIN透過展場本機AP傳送,功能是避免一般參觀者誤觸管理命令,並非網際網路等級的安全機制。若未來開放外網,必須改用HTTPS與正式身分驗證。

六、管理後台可以做什麼?

  • 查看目前狀態、曲目、音量、運作秒數及開機次數。
  • 查看白馬、烏龜、姻緣花、總故事及創作者資訊的播放次數。
  • 區分實體按鈕、一般Web與管理操作。
  • 查看停止、切歌及JQ6500復原次數。
  • 查看最近20筆事件及最後一次操作。
  • 測試001~009全部曲目。
  • 遠端停止、調整音量、重設JQ6500及立即保存。
  • 切換測試模式,開啟每3秒一次的GPIO診斷。
  • 經確認後清除使用統計;音量與開機次數保留。

七、Flash不是每次操作都寫入

若每按一次按鈕就把十多個計數器逐一寫入Flash,不只可能影響按鈕反應,也會快速增加擦寫次數。V1.0-08採用兩層延遲:

#define VOLUME_SAVE_DELAY_MS  3000
#define STATS_SAVE_DELAY_MS   60000UL
  • 音量停止調整3秒後才保存。
  • 一般統計累積60秒後才保存。
  • 所有永久資料打包成一個PersistentData結構,一次寫回。
  • 自動保存只在播放器真正待機時執行,避免影響播放中切歌與停止。
  • 管理員仍可按「立即保存」。
bool safeToWrite = playerState == STATE_IDLE &&
                   digitalRead(JQ_BUSY_PIN) == busyIdleLevel;

if ((volumeDue || statsDue) && safeToWrite) {
    savePersistentData(...);
}

八、資料完整性與V1.0-07遷移

永久資料包含Magic、Schema版本與Checksum。重新開機時只有三項都正確才採用;第一次從V1.0-07升級時,程式會辨識舊格式,自動承接原本音量與開機次數,再轉成V1.0-08新結構。

[FLASH] Migrated V1.0-07 volume and boot count

看到這行訊息代表遷移成功,不是錯誤。最近20筆事件則只放在RAM,重新開機後重新開始,避免Flash承受高頻事件紀錄。

九、最重要的穩定原則仍然保留

V1.0-08沒有改變已成功的主迴圈優先順序:

handleButtons();              // 實體按鈕最高優先
updateBusy();                 // JQ6500 BUSY
updatePlayerStateMachine();   // 非阻塞播放器狀態機
handleWeb();                  // Web放在後面
updateLed();
updatePersistentSettings();

而AmebaPro2 Web能與按鈕共存的關鍵依然是:

server.setNonBlockingMode();
server.begin();

十、實機測試結果

測試項目結果說明
V1.0-07資料遷移通過音量與開機次數可承接
三個實體故事按鈕通過播放與統計同步
停止及播放中切歌通過非阻塞狀態機正常
手機故事首頁通過播放、停止、音量正常
管理PIN通過錯誤PIN拒絕管理API
管理曲目測試通過001~009均可測試
本機統計通過來源與故事次數正確
最近20筆事件通過以新到舊顯示
Flash延遲保存通過待機時單次批次寫入
LED燈號通過HIGH點亮,狀態一致
沒有手機連線通過實體按鈕仍可持續操作

十一、完整Arduino程式

開發板套件:ideasHatch AmebaPro2 4.1.1-Release。使用WiFi.hFlashMemory.h

展開/收合 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,就能用手機確認設備狀態、測試曲目與處理播放器。

更重要的是,所有新增功能都遵守同一原則:網路不能阻塞按鈕、統計不能干擾聲音、Flash不能破壞即時操作。這是故事機能從桌面實驗走向地方展場的真正關鍵。

HUB 8735 Ultra+JQ6500:展場整合、斷電恢復與LED狀態完成

水井三寶故事機|V1.0-07

HUB 8735 Ultra+JQ6500:展場整合、斷電恢復與LED狀態完成

從「能播放」走向「能長時間展示」:實體按鈕、手機Web、播放中切歌、停止、音量記憶、BUSY保護及燈號提示,已完成整體測試。

最終測試結果:開機提示、準備完成、三個故事按鈕、Web控制、中途切歌、停止、音量加減、Flash音量記憶與LED顯示均正常。
6鍵故事、停止與音量
9軌故事及系統提示音
AP+Web手機免安裝操作
非阻塞按鈕保持最高優先

一、V1.0-07完成了什麼?

V1.0-06已解決AmebaPro2網路伺服器預設阻塞造成「沒有手機連線時,實體按鈕也停住」的問題。V1.0-07沿用最重要的server.setNonBlockingMode(),再補上展場需要的斷電恢復、狀態燈與播放器自動保護。

  • AP模式:手機連線後,以瀏覽器開啟控制頁面。
  • 六個實體按鈕:三個故事、停止、音量+、音量-。
  • 播放中按另一個故事,可中斷目前曲目並切換。
  • 音量寫入Flash,斷電重新啟動後自動恢復。
  • 記錄累計開機次數及播放器自動復原次數。
  • JQ6500 BUSY未啟動或長時間異常時自動復原。
  • 板載綠、藍LED顯示待機、播放、AP及錯誤狀態。

二、硬體與接線

HUB 8735 Ultra連接對象功能
GPIO8(Serial2 TX)JQ6500 RX傳送播放器命令
GPIO15(Serial2 RX)JQ6500 TX保留播放器回傳通道
GPIO4JQ6500 BUSY偵測播放開始及完成
GPIO10白馬按鈕另一端接GND播放001.mp3
GPIO11烏龜按鈕另一端接GND播放002.mp3
GPIO12姻緣花按鈕另一端接GND播放003.mp3
GPIO20停止按鈕另一端接GND停止並播放009提示
GPIO7音量+按鈕另一端接GND每次增加2級
GPIO6音量-按鈕另一端接GND每次降低2級
LED_G/LED_B板載綠燈/藍燈系統狀態顯示

JQ6500通訊接線圖

HUB 8735 Ultra
GPIO8/Serial2 TX
JQ6500 RX
HUB 8735 Ultra
GPIO15/Serial2 RX
JQ6500 TX
HUB 8735 Ultra
GPIO4
JQ6500 BUSY
HUB 8735 Ultra
GND
JQ6500 GND(必須共地)
供電注意:先前使用電腦USB供電時曾反覆重置並重播006開機語音。改用穩定、合格的AC轉DC 5V電源後系統穩定。市電不可直接接入開發板,JQ6500、HUB 8735 Ultra與功率放大器必須確認電壓並共地。

三、JQ6500曲目配置

檔名/曲目內容觸發來源
001.mp3白馬故事GPIO10/Web
002.mp3烏龜故事GPIO11/Web
003.mp3姻緣花故事GPIO12/Web
004.mp3水井三寶總故事Web
005.mp3創作者資訊Web
006.mp3開機提示系統啟動
007.mp3準備完成006播放完成後
008.mp3錯誤提示播放器異常復原
009.mp3停止提示停止命令

四、LED極性:最後一個重要修正

測試影片中一開始曾懷疑BUSY狀態與燈號不同步,後來重新檢查才確認是板載LED極性。HUB 8735 Ultra在這次實測中採用HIGH點亮、LOW熄滅,因此最終設定為:

#define LED_ON  HIGH
#define LED_OFF LOW
狀態燈號意義
IDLE綠燈恆亮系統待機,可接受按鈕或Web命令
播放/提示音藍燈恆亮JQ6500正在播放
AP尚未就緒綠燈亮、藍燈閃爍實體按鈕仍然可以使用
播放器錯誤綠、藍交替系統正在執行自動復原
這次經驗:看到燈號與聲音不一致時,不應立刻判定BUSY訊號錯誤;必須先確認LED是高電位還是低電位點亮。硬體極性判斷錯誤,會讓正確的狀態機看起來像是反向運作。

五、為什麼Web與按鈕可以同時穩定?

AmebaPro2的WiFiServer預設可能採用阻塞模式。若直接呼叫server.available(),沒有手機連線時,主迴圈可能停在等待網路用戶端,按鈕、BUSY與LED就無法持續更新。關鍵修正如下:

server.setNonBlockingMode();
server.begin();

主迴圈則明確安排優先順序:

void loop()
{
    handleButtons();              // 第一優先:實體按鈕
    updateBusy();                 // 第二優先:播放器BUSY
    updatePlayerStateMachine();   // 第三優先:非阻塞狀態機

    if (wifiReady) {
        handleWeb();              // 網路服務放在後面
    } else if (millis() - lastApRetryAt >= 15000) {
        lastApRetryAt = millis();
        tryStartAPOnce();          // 單次AP重試,不連續卡住
    }

    updateLed();
    updatePersistentSettings();
    delay(3);
}

六、音量斷電記憶與Flash保護

V1.0-07使用AmebaPro2的FlashMemory.h保存音量與開機次數。為避免使用者連按音量鍵時頻繁擦寫Flash,程式不會每按一次就立刻儲存,而是最後一次調整後等待3秒再合併寫入。

#define VOLUME_SAVE_DELAY_MS 3000

if (remember && changed) {
    volumeDirty = true;
    volumeChangedAt = millis();
}

重新開機後會讀回上次音量;若第一次執行尚無有效資料,就使用預設音量20。

七、JQ6500非阻塞中斷與異常復原

播放中切換故事時,程式不使用長時間delay()等待,而是依序進入PAUSE_WAITRESET_WAIT,最後恢復音量與單曲停止模式,再播放新的曲目。

Pause(0x0E)
   ↓ 等待150ms
Reset(0x0C)
   ↓ 等待700ms
恢復音量與ONE_STOP
   ↓
播放目標曲目

另外加入三種保護:

  • 送出曲目後1.8秒仍未偵測BUSY開始,執行播放器復原。
  • 故事最長播放30分鐘,避免BUSY永久卡住。
  • 009停止提示即使BUSY異常,也會在最長8秒後釋放主狀態。

八、AP與手機Web操作

  1. 手機Wi-Fi連線至Shuijing-Story
  2. 輸入密碼shuijing8735
  3. 瀏覽器開啟http://192.168.1.1/
  4. 選擇白馬、烏龜、姻緣花、總故事或創作者資訊。
  5. 播放中可切換故事、停止或調整音量。

Web頁面同步顯示目前狀態、曲目、BUSY、音量、運作時間、開機次數、播放器復原次數及韌體版本。

九、完整測試清單

測試項目結果觀察重點
006開機提示通過只播放一次,未反覆重置
007準備完成通過006完成後自動播放
三個故事按鈕通過GPIO10、11、12對應正確
播放中切換故事通過目前曲目中止後播放新故事
停止按鈕通過可停止並播放009,不重複堆疊
音量+/-通過範圍0~30,每次2級
斷電音量恢復通過重新開機保留上次音量
手機Web通過故事、停止與音量均正常
沒有手機連線通過實體按鈕仍持續掃描
LED燈號通過HIGH點亮後與狀態一致

十、完整Arduino程式

開發環境使用ideasHatch AmebaPro2 4.1.1-Release。程式使用WiFi.hFlashMemory.h,請確認開發板套件版本具備FlashMemory函式庫。

展開/收合 V1.0-07 完整程式
/*
 * =====================================================================
 * 水井三寶故事機|V1.0-07
 * HUB 8735 Ultra + JQ6500 展場整合/斷電恢復版
 * =====================================================================
 * AP:Shuijing-Story / shuijing8735
 *
 * 曲目:
 *  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>

char AP_SSID[] = "Shuijing-Story";
char AP_PASS[] = "shuijing8735";
char AP_CHANNEL[] = "1";
int AP_HIDDEN = 0;

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-07"

// AmebaPro2官方FlashMemory保留區;只用第一個4KB sector。
#define SETTINGS_FLASH_BASE       0xFD0000
#define SETTINGS_FLASH_SIZE       0x1000
#define SETTINGS_WORD_OFFSET      0x0000
#define BOOT_COUNT_WORD_OFFSET    0x0004
#define SETTINGS_MAGIC            0x53560700UL
#define SETTINGS_MAGIC_MASK       0xFFFFFF00UL
#define VOLUME_SAVE_DELAY_MS      3000

// 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
};

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 errorRecoveryActive = false;
unsigned long volumeChangedAt = 0;
unsigned long bootCount = 0;
unsigned long playerErrorCount = 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();
    }
}

void loadPersistentSettings()
{
    FlashMemory.begin(SETTINGS_FLASH_BASE, SETTINGS_FLASH_SIZE);

    unsigned long settings = FlashMemory.readWord(SETTINGS_WORD_OFFSET);
    if ((settings & SETTINGS_MAGIC_MASK) == SETTINGS_MAGIC) {
        int savedVolume = settings & 0xFF;
        if (savedVolume >= VOL_MIN && savedVolume <= VOL_MAX) {
            currentVolume = savedVolume;
            Serial.print("[FLASH] Restored volume = ");
            Serial.println(currentVolume);
        }
    } else {
        Serial.println("[FLASH] No valid settings; use default volume 20");
    }

    unsigned long savedBootCount = FlashMemory.readWord(BOOT_COUNT_WORD_OFFSET);
    if (savedBootCount == 0xFFFFFFFFUL) savedBootCount = 0;
    bootCount = savedBootCount + 1;
    FlashMemory.writeWord(BOOT_COUNT_WORD_OFFSET, bootCount);
    Serial.print("[FLASH] Boot count = ");
    Serial.println(bootCount);
}

void updatePersistentSettings()
{
    if (!volumeDirty || millis() - volumeChangedAt < VOLUME_SAVE_DELAY_MS) return;

    unsigned long settings = SETTINGS_MAGIC | (unsigned long)currentVolume;
    FlashMemory.writeWord(SETTINGS_WORD_OFFSET, settings);
    volumeDirty = false;
    Serial.print("[FLASH] Volume saved = ");
    Serial.println(currentVolume);
}

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++;
    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)
{
    // 即使狀態已被009 Watchdog釋放,只要BUSY仍在播放,仍先中斷再切歌。
    bool hardwareBusy = digitalRead(JQ_BUSY_PIN) != busyIdleLevel;

    if (playerState == STATE_IDLE && !hardwareBusy) {
        playDirect(track, title, STATE_STORY_PLAYING);
    } else if (playerState == STATE_AP_STARTING || playerState == STATE_ERROR) {
        Serial.println("[COMMAND] Rejected: system not ready");
    } else {
        beginInterrupt(track, title, false);
    }
}

void requestStop()
{
    if (playerState == STATE_AP_STARTING || playerState == STATE_ERROR) {
        Serial.println("[STOP] Ignored in current state");
        return;
    }

    // 009播放期間再按停止:中斷009並回到待機,不再重播009。
    if (playerState == STATE_STOP_PROMPT) {
        Serial.println("[STOP] Stop prompt cancelled by user");
        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");
            beginInterrupt(0, "", false);
        } else {
            Serial.println("[STOP] Ignored: already idle");
        }
        return;
    }

    if (millis() - lastStopAt < 1000) {
        Serial.println("[STOP] Duplicate ignored");
        return;
    }
    lastStopAt = millis();
    beginInterrupt(TRACK_STOP, "停止提示", true);
}

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 (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, "白馬故事");
    else if (pressed[1]) requestStory(TRACK_TURTLE, "烏龜故事");
    else if (pressed[2]) requestStory(TRACK_FLOWER, "姻緣花故事");
    else if (pressed[3]) requestStop();
    else if (pressed[4]) jqSetVolume(currentVolume + VOL_STEP);
    else if (pressed[5]) jqSetVolume(currentVolume - VOL_STEP);
}

// ---------------------------------------------------------------------
// 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)
{
    client.println(code == 200 ? "HTTP/1.1 200 OK" : "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 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.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-07|展場整合與斷電恢復版</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|http://192.168.1.1/</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");
}

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();
}

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 ")) {
        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;
        }
    }

    if (req.startsWith("GET /api/play/horse ")) requestStory(TRACK_HORSE, "白馬故事");
    else if (req.startsWith("GET /api/play/turtle ")) requestStory(TRACK_TURTLE, "烏龜故事");
    else if (req.startsWith("GET /api/play/flower ")) requestStory(TRACK_FLOWER, "姻緣花故事");
    else if (req.startsWith("GET /api/play/all ")) requestStory(TRACK_ALL, "水井三寶總故事");
    else if (req.startsWith("GET /api/play/creator ")) requestStory(TRACK_CREATOR, "創作者資訊");
    else if (req.startsWith("GET /api/stop ")) requestStop();
    else if (req.startsWith("GET /api/volume/up ")) jqSetVolume(currentVolume + VOL_STEP);
    else if (req.startsWith("GET /api/volume/down ")) jqSetVolume(currentVolume - VOL_STEP);
    else if (req.startsWith("GET /api/volume/set?")) jqSetVolume(queryValue(req, "value", currentVolume));

    if (req.startsWith("GET /api/")) sendStatus(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());
    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-07");
    Serial.println("Exhibition Integration and Power Recovery Edition");
    Serial.println("================================================");

    loadPersistentSettings();

    // 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);
}

十一、結語:展場穩定不是「功能更多」,而是「每一層都不互相卡住」

這一版最重要的成果,不只是增加Flash、LED或Web資訊,而是建立了清楚的優先順序:按鈕不等待網路、網路不阻塞播放器、提示音不鎖住主狀態、BUSY異常能自動復原,供電也改用穩定的5V來源。

水井三寶故事機因此從一塊能播放MP3的實驗板,逐步成為可讓民眾以實體按鈕或手機操作、可在地方文化展示現場穩定運作的互動故事載具。

2026年8月23日 星期日

[水井村USR] 從「Web一開、按鈕就停」到雙介面穩定運作:HUB 8735 Ultra+JQ6500除錯全紀錄

水井三寶故事機|V1.0-06

從「Web一開、按鈕就停」到雙介面穩定運作:HUB 8735 Ultra+JQ6500除錯全紀錄

這次實作最寶貴的成果,不只是故事能播放,而是我們透過序列監控、狀態機、電源A/B測試與函式庫原始碼,一步步找出「供電重置、AP阻塞、Web Server阻塞、BUSY判斷與停止提示」之間的關係,最後讓手機Web與實體按鈕可以同時穩定操作。

一、最終完成的系統

1獨立APHUB自行建立Shuijing-Story,不需要外部路由器。
2雙操作介面手機Web為主要介面,實體按鈕是立即可用的離線備援。
3非阻塞狀態機播放、切歌、停止、Reset與提示音由統一狀態機管理。
最終測試結果:沒有手機連線時,三個故事按鈕與停止鍵可正常操作;手機連入後,Web與實體按鈕仍能交替使用,不再因等待Web客戶端而停住。

二、硬體與曲目配置

元件工作本次設計角色
HUB 8735 UltraAP、Web、GPIO與狀態機故事機主控制器
JQ6500MP3解碼、播放、暫停與音量專用音訊播放器
手機瀏覽器控制主要使用介面
實體按鈕故事選擇、停止與音量沒有手機時仍可操作
穩定5V電源供應Wi-Fi與音訊瞬間電流展場穩定運作的基礎

MP3曲目

檔案內容啟動方式
001.mp3白馬故事Web或GPIO10
002.mp3烏龜故事Web或GPIO11
003.mp3姻緣花故事Web或GPIO12
004.mp3水井三寶總故事Web
005.mp3創作者資訊Web
006.mp3開機提示自動
007.mp3準備完成006結束後自動
008.mp3錯誤提示錯誤狀態
009.mp3停止提示停止故事後播放

三、最終接線圖

GPIO8
Serial2 TX
JQ6500 RX
 
播放命令
GPIO15
Serial2 RX
JQ6500 TX
 
狀態回傳
GPIO4
JQ6500 BUSY
 
播放狀態
HUB GND
JQ6500 GND
共同參考地
GPIO功能按鈕接法
GPIO10白馬GPIO10→按鈕→GND
GPIO11烏龜GPIO11→按鈕→GND
GPIO12姻緣花GPIO12→按鈕→GND
GPIO20停止GPIO20→按鈕→GND
GPIO7音量+GPIO7→按鈕→GND
GPIO6音量-GPIO6→按鈕→GND
按鈕電位:程式使用INPUT_PULLUP,未按為1、按下為0。GPIO13裸接仍為0,因此本專案最後改用GPIO20作為停止鍵。

四、除錯歷程:每個症狀都在說話

階段1|HUB AOUT可以播放,但Web難以中途控制
原本使用HUB的阻塞式MP3播放。故事播放期間,主程式無法持續處理Web、停止與切歌,因此改由JQ6500專責播放,HUB只負責控制。
階段2|AP可以連,但開機提示不斷重播
一開始以為006被設定成循環,後來從序列監控發現程式標題與Boot Loader不斷重現,真正原因是整塊HUB反覆重置。
階段3|改用穩定電源後重置消失
使用PC USB孔供電時,Wi-Fi AP與音訊啟動的瞬間負載可能造成壓降;改用合格市電轉5V電源後,系統恢復穩定。
階段4|Web正常,停止與切歌卻會排隊
JQ6500的Pause、Reset與指定曲目命令需要正確時序,因此建立PAUSE_WAIT、RESET_WAIT、STORY_PLAYING與STOP_PROMPT狀態。
階段5|009停止提示讓系統停在STOP_PROMPT
009太短或BUSY沒有形成完整變化時,狀態機無法回到待機,因此加入BUSY啟動逾時與播放上限Watchdog。
階段6|Web輪詢造成FreeRTOS Queue斷言
每秒短連線與大量序列輸出增加底層資源壓力,最後將狀態更新改為每3秒、防止重疊請求,並在關閉Socket後保留釋放時間。
階段7|沒有手機時,系統停在「啟動中」
這是最關鍵的發現:AmebaPro2的WiFiServer預設採用BLOCKING_MODE。沒有手機時,server.available()會一直等待,整個主迴圈因此停止。

五、經驗一:重複語音不一定是MP3循環

曾經看到006開機語音反覆播放,直覺會先懷疑JQ6500的循環模式。然而序列監控不斷重新出現:

================================================
HUB 8735 Ultra + JQ6500 Web Story Player
================================================

甚至再次出現:

== Rtl8735b IoT Platform ==
[Normal mode]
BootFromNORFlash

這代表不是單一曲目循環,而是開發板重新開機。每次進入setup()都會再次播放006,所以聽起來才像無限循環。

除錯原則:先判斷「播放器重播」還是「整板重開」,兩者需要完全不同的修正方向。

六、經驗二:穩定供電是Wi-Fi音訊系統的一部分

單獨測試HUB、JQ6500或Web可能都正常,但整合後會同時產生:

  • Wi-Fi射頻啟動的瞬間電流。
  • JQ6500解碼與儲存讀取負載。
  • 功率放大器及喇叭的動態電流。
  • USB線材與接點造成的壓降。

實測結果是:PC USB供電容易發生重置,使用穩定的市電轉5V電源供應器後,AP、Web及音訊都能長時間正常運作。

安全提醒:市電必須先經合格且規格正確的AC轉DC 5V電源供應器,絕不可將110V直接接入HUB、JQ6500或麵包板。多組電源只共地,不可任意把兩個5V輸出直接並聯。

七、經驗三:AP重試也必須非阻塞

舊設計一次執行6次AP啟動,每次失敗等待10秒:

for (int attempt = 1; attempt <= 6; attempt++) {
    WiFi.apbegin(...);
    delay(10000);
}

最壞情況會有60秒無法掃描按鈕。後來改成一次只嘗試一次,失敗立即返回,15秒後再試:

bool tryStartAPOnce()
{
    int status = WiFi.apbegin(AP_SSID, AP_PASS,
                              AP_CHANNEL, AP_HIDDEN);
    if (status != WL_CONNECTED) {
        wifiReady = false;
        return false;
    }
    wifiReady = true;
    return true;
}

AP失敗不再改變播放器狀態,三個故事鍵、停止鍵、音量與BUSY仍繼續運作。

八、最關鍵發現:WiFiServer預設會阻塞

即使AP重試已經非阻塞,沒有手機連線時,按鈕仍停在開機後第一筆狀態。最後直接查閱AmebaPro2的WiFiServer實作,發現預設值是:

tBlockingMode _is_blocked = BLOCKING_MODE;

因此執行:

WiFiClient client = server.available();

在沒有手機時可能一直等待客戶端,主迴圈無法再執行按鈕、BUSY與播放器狀態機。真正的修正只有一行,但必須放在server.begin()之前:

server.setNonBlockingMode();
server.begin();
修正後:沒有手機時,server.available()立即返回;手機連入後,Web仍可正常回應。這一行讓「Web+實體按鈕」真正可以共存。

九、主迴圈的最終優先順序

展場故事機的核心不是網路,而是使用者按下按鈕後必須立即有反應。因此最終順序設計為:

void loop()
{
    handleButtons();              // 最高:實體按鈕
    updateBusy();                 // 第二:音訊硬體狀態
    updatePlayerStateMachine();   // 第三:播放狀態機

    if (wifiReady) {
        handleWeb();              // 第四:Web
    } else if (到達15秒) {
        tryStartAPOnce();         // 最後:AP恢復
    }

    updateLed();
    delay(3);
}
實體按鈕BUSY播放器狀態機WebAP重試

十、JQ6500非阻塞播放狀態機

狀態意義可接受操作
BOOT_PROMPT播放006可被故事或停止命令中斷
READY_PROMPT播放007可切換故事
IDLE等待操作所有故事與音量
STORY_PLAYING播放001~005停止或立即切歌
PAUSE_WAIT送出Pause後等待150ms更新最終目標,不重複排隊
RESET_WAITReset後等待700ms保留最後命令
STOP_PROMPT播放009可取消或改播故事
ERROR系統錯誤顯示診斷

中途切歌流程:

故事播放中
→ 0x0E Pause立即中斷
→ PAUSE_WAIT 150ms
→ 0x0C Reset清除Pause與待播狀態
→ RESET_WAIT 700ms
→ 恢復音量與ONE_STOP
→ 播放最後選擇的故事

十一、009停止提示不再鎖住系統

009可能很短,BUSY變化可能未被掃描到。因此加入兩層Watchdog:

#define STOP_BUSY_START_TIMEOUT_MS 600
#define STOP_PROMPT_MAX_MS        8000
  • 009送出600ms後沒有BUSY啟動:自動回到待機。
  • BUSY長時間沒有結束:8秒後強制釋放狀態。
  • 009播放時選故事:立即中斷009並切歌。
  • 009播放時再按停止:取消009,不重複播放。

十二、Web輪詢與FreeRTOS Queue經驗

早期Web每秒建立一次狀態連線,並把每個請求與六個GPIO值大量印到序列監控,曾觸發:

line 1473
freertos_v202012.00/Source/queue.c

最後採取:

  • 狀態輪詢改為每3秒。
  • 前一個請求未完成時,不送出重疊請求。
  • 不列印每一次/api/status
  • client.stop()後保留短暫資源釋放時間。
  • 按鈕原始電位診斷降低輸出頻率。

這提醒我們:嵌入式Web不是一般伺服器,Socket、記憶體、RTOS Queue與序列輸出都必須節制。

十三、如何用序列監控快速判讀

序列現象代表意義處理方向
程式標題不斷重現整板重置檢查電源與Reset
Download Image over UART1進入UART燒錄模式檢查功能鍵與BOOT_MODE
只有一筆BUTTON RAW後停止主迴圈被阻塞檢查WiFiServer模式
G10由1變0白馬按鈕硬體正常追查狀態機
BUSY離開idle再回idle曲目完整開始與結束狀態可回IDLE
STOP_PROMPT沒有BUSY009未啟動或太短交由Watchdog釋放
queue.c斷言RTOS資源或非執行緒安全問題降低輪詢與輸出頻率

十四、最終驗收結果

V1.0-06驗收通過:使用穩定5V供電,HUB可建立AP;沒有手機時按鈕持續更新,手機連入後Web與按鈕都能正常;故事播放、播放中切歌、停止提示、BUSY狀態及AP重試均不再長時間鎖住主迴圈。
  • AP名稱:Shuijing-Story
  • Web網址:http://192.168.1.1/
  • Web狀態更新:每3秒一次
  • 按鈕:GPIO10、11、12、20
  • 播放器:JQ6500 UART+BUSY
  • 核心原則:按鈕最高優先、網路可以失敗、故事機仍能工作

十五、這次最重要的學習

真正可靠的互動裝置,不能只在「所有條件都正常」時工作。網路可能沒有客戶端、AP可能啟動失敗、提示音可能太短、BUSY可能漏掉、USB供電可能壓降。良好的系統設計必須讓每個外部條件都可以失敗,但不能拖垮故事播放與實體按鈕。

一句話總結:讓Web與實體按鈕共存的關鍵,不只是寫出Web頁面,而是確保任何網路等待都不能阻塞主迴圈。
Blogger貼文提醒:請切換至Blogger「HTML檢視」後貼入本檔全部內容。本檔沒有<html><head><body>外層標籤;表格與程式碼已設定橫向捲動,不會把右側資訊欄擠出畫面。