2026年8月24日 星期一

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的實驗板,逐步成為可讓民眾以實體按鈕或手機操作、可在地方文化展示現場穩定運作的互動故事載具。

沒有留言:

張貼留言