2026年8月15日 星期六

[水井村USR] ESP32 AP+STA 到 Django 雲端:水井三寶智慧互動展覽系統 Layer 4 V1.7 實作紀錄

ESP32 AP+STA 到 Django 雲端:水井三寶智慧互動展覽系統 Layer 4 V1.7 實作紀錄

從 JQ6500 故事播放、Edge Web、NVS Wi‑Fi 設定,到 Public DNS 修正與匿名事件雲端同步

實作版本:Layer 4 V1.7|Public DNS Fix

這次測試最重要的成果,不只是「ESP32 終於可以把資料送到雲端」,而是把一個展覽原型逐步整理成具有離線可操作、現場可診斷、網路可設定、事件可暫存、恢復連線可同步的 Edge+Cloud 架構。最後的 V1.7 已能讓 ESP32 同時維持本地 AP 與 Internet STA,透過 192.168.4.1 控制故事,並將匿名互動事件送往 Django 儀表板。

一、系統從「會播放」走向「真正可展覽」

整體可理解成四層:第二層負責 ESP32、ToF、三按鈕、JQ6500 與 RGB 的智慧互動;第三層加入 ESP32 內嵌 Edge Web,讓工作人員可用手機控制與診斷;第四層再把匿名事件送到 Django,形成展覽統計與設備健康資訊。

觀眾/工作人員 ↓ 實體按鈕 / ESP32 Edge Web ↓ JQ6500 故事播放 + 狀態判斷 ↓ LittleFS Event Queue ↓ STA → DNS → HTTPS ↓ PythonAnywhere / Django API ↓ 匿名事件儀表板

二、AP+STA 共存是展場架構的關鍵

ESP32 的 AP 並不是拿來取代 Internet,而是保留一條不依賴展場網路的本地控制通道。手機連上 Shuijing-Treasures 後,可直接進入 192.168.4.1;同一時間 STA 連上可上網的 Wi‑Fi,負責把事件送往 Django。即使外網失效,AP 與本地故事控制仍可工作。

Layer 4 V1.7 系統狀態:STA ONLINE、DNS 8.8.8.8、Queue 與故事播放狀態
圖 1 Layer 4 V1.7 系統狀態:STA ONLINE、DNS 8.8.8.8、Queue 與故事播放狀態

V1.7 畫面可直接看到 AP 位址、STA 是否 ONLINE、STA IP、RSSI、DNS 與 Queue 大小。這些資訊對展場維護非常重要,因為「手機能控制」與「雲端能同步」其實是兩條不同的網路路徑。

三、測試中最關鍵的轉折:HTTP -1 的根因其實是 DNS

前期曾反覆看到 Cloud HTTP = -1。一開始容易懷疑 Django API、TLS、API Key 或 PythonAnywhere,但加入逐層診斷後,真正有決定性的紀錄是:

WiFi status = 3
STA IP = 192.168.1.119
Gateway = 192.168.1.1
DNS = 192.168.1.1
RSSI = -20
Resolving: shuijingtreasures.pythonanywhere.com
DNS result = -54
ERROR: DNS FAILED
重要經驗:「STA=ONLINE」只代表 ESP32 已加入 Wi‑Fi 並取得 IP,不代表 DNS、TLS、HTTPS 與 Django 都已經正常。除錯時必須拆成 Wi‑Fi → Gateway → DNS → TLS → HTTP → API 六個階段。

因此 V1.7 不再只相信 DHCP 配發的 DNS,而是在 STA 連線成功後保留 DHCP IP,另外指定 Public DNS:

IPAddress DNS1(8, 8, 8, 8);
IPAddress DNS2(1, 1, 1, 1);

WiFi.setDNS(DNS1, DNS2);

修正後 Edge Web 已顯示 DNS:8.8.8.8,而 Cloud Test 也正式成功。

Cloud Test OK:Django 已接受 ESP32 上傳事件
圖 4 Cloud Test OK:Django 已接受 ESP32 上傳事件
驗證結果:Cloud Test OK,畫面顯示「Django 已接受事件」。這代表 DNS 解析、HTTPS 傳輸、裝置驗證與 Django API 接收這條路徑已經打通。

四、JQ6500:送出播放命令不等於真的播放

另一個重要經驗來自 JQ6500。早期程式只要送出 UART 命令就把狀態設成 PLAYING,但實測曾出現「程式顯示已播放、喇叭卻沒有聲音」。因此後續版本把 JQ6500 的 BUSY/播放確認與 Recovery 納入狀態判斷,不再把「已送命令」當成「播放成功」。

Layer 4 V1.7 故事控制與設備診斷:JQ6500 BUSY、Cloud OK / Fail
圖 2 Layer 4 V1.7 故事控制與設備診斷:JQ6500 BUSY、Cloud OK / Fail

目前設備診斷畫面可看到 JQ BUSY、Start Confirmed、Retry、Fail,以及 Cloud OK / Fail。這使現場人員不用接電腦,也能從手機快速判斷問題是在音訊端還是網路端。

五、Web 狀態不能只靠重新整理

測試過程也發現:故事播完後,如果 Edge Web 還停留在 PLAYING,下一次操作會讓人誤以為系統延遲。這說明嵌入式 Web 不應只是「控制按鈕頁」,還應該是設備狀態的即時視窗。V1.7 延續前面的播放狀態管理,讓 PLAYING、IDLE、目前故事與 JQ 狀態有一致的資料來源。

早期 Edge Web Control V1.0 畫面,作為 Layer 3 到 Layer 4 演進比較
圖 6 早期 Edge Web Control V1.0 畫面,作為 Layer 3 到 Layer 4 演進比較

六、匿名事件 Queue:網路斷線也不能讓展覽失憶

展場網路不可能永遠穩定,因此事件不是「有網路才記錄」。V1.7 先將 STORY_START、STORY_COMPLETE 等匿名事件排入 LittleFS Queue,再由 Cloud Sync 在適當時機送出。畫面中的 Queue: 14096 bytes 就是尚待同步的本地事件資料。

這個設計的價值是:互動優先、上雲其次。播放故事時避免 HTTPS 與 Wi‑Fi reconnect 搶資源;空閒後再同步,才符合實體展覽設備的可靠性需求。

七、從 ESP32 即時統計到 Django 儀表板

Edge Web 提供的是「這一台設備現在發生什麼事」,Django 則負責「今天整體發生多少互動」。兩者角色不同但互補。

ESP32 即時匿名統計:故事、Web、實體按鈕、展區喚醒與完整播放
圖 3 ESP32 即時匿名統計:故事、Web、實體按鈕、展區喚醒與完整播放

ESP32 本地頁可看到白馬、烏龜、姻緣花、004、005、Web、實體按鈕、展區喚醒與完整播放等即時匿名統計。這些資料不需要辨識個人身分,而是記錄設備互動事件。

PythonAnywhere Django 雲端儀表板:故事啟動、完整播放、互動來源與裝置健康
圖 5 PythonAnywhere Django 雲端儀表板:故事啟動、完整播放、互動來源與裝置健康

雲端儀表板已收到 Layer4‑V1.7 資料,可顯示故事啟動、完整播放、播放完成率、三寶與延伸故事、互動來源,以及 SHUIJING‑001 裝置健康。測試畫面中已有 9 次故事啟動、2 次完整播放,Edge Web 為主要互動來源,裝置狀態為 ONLINE。

八、這次除錯得到的工程經驗

現象容易誤判最後得到的經驗
STA ONLINE以為 Internet 一定正常還要分別驗證 Gateway、DNS、TLS、HTTP
Cloud HTTP = -1先懷疑 Django本次真正根因是 DNS 解析失敗
JQ TX 已送出以為故事一定播放必須用 BUSY/確認機制判斷實際播放
Web 顯示 PLAYING以為狀態一定同步播放完成後狀態也要回到 IDLE
Cloud 暫時離線事件可能直接遺失先進 LittleFS Queue,恢復網路後再同步
展場有 Internet認為不需要 APAP 是維護與現場控制的獨立生命線

九、V1.7 的設計原則

AP+STA 共存Public DNSNVS Wi‑Fi 設定JQ6500 RecoveryLittleFS QueueHTTPSDjango API匿名統計Edge Web

如果只把 ESP32 當成「播放 MP3 的控制板」,這套系統並不複雜;但當目標變成真正可長時間展出的作品,問題就會轉向可恢復性、可診斷性、離線能力與資料一致性。V1.7 的價值正是在這裡:不是增加更多炫目的功能,而是讓每一層失效時,其他層仍能盡量維持工作。

十、Layer 4 V1.7 完整 Arduino 程式

以下為本次實測版本的完整程式。實際部署前,請依自己的 Django 裝置資料設定 API Key,並避免在公開文章中放入正式金鑰或 Wi‑Fi 密碼。

Shuijing_Treasures_Layer4_V1_7_PublicDNSFix.inoLayer 4 V1.7
/*
====================================================================
 水井三寶智慧互動展覽系統
 Shuijing Treasures Smart Interactive Exhibition System

 Layer 4|Django Data Layer V1.7
 Cloud Diagnostic + Local First / Cloud Later
--------------------------------------------------------------------
 ESP32 NodeMCU-32S
 + VL53L0X ToF
 + 3 Buttons
 + JQ6500
 + WS2812
 + SoftAP
 + STA Internet
 + Embedded Web
 + NVS Wi-Fi Setting
 + LittleFS Offline Event Queue
 + Django Event API / Heartbeat
 + JQ6500 播放確認 / Recovery
 + Cloud DNS / TLS / HTTP 診斷

 本版重點:
 1. 播放故事優先,播放中完全不做 Django HTTPS。
 2. READY / IDLE 都允許同步,只要最近 10 秒沒有本地操作。
 3. /cloudtest 可直接測試 Django API。
 4. postJsonToCloud() 會依序檢查:
      WiFi status
      STA IP
      Gateway
      DNS
      hostByName()
      TCP/TLS 443
      HTTP begin
      HTTP POST
      response body
 5. HTTPS connect/response timeout = 10 秒。
 6. 每次 Queue 同步最多 1 筆,避免長時間阻塞。
 7. Wi-Fi / Cloud 診斷每 30 秒一次。

 AP:
   SSID     = Shuijing-Treasures
   Password = 12345678
   URL      = http://192.168.4.1

 Wi-Fi Setup:
   http://192.168.4.1/wifi

 Cloud Test:
   http://192.168.4.1/cloudtest

 Track:
   001 白馬故事
   002 烏龜故事
   003 姻緣花故事
   004 水井三寶總故事
   005 創作者資訊
====================================================================
*/

#include <Arduino.h>
#include <WiFi.h>
#include <WiFiAP.h>
#include <WebServer.h>
#include <Preferences.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <LittleFS.h>
#include <Wire.h>
#include <HardwareSerial.h>
#include <Adafruit_VL53L0X.h>
#include <Adafruit_NeoPixel.h>

// ==========================================================
// Version
// ==========================================================

const char* FIRMWARE_VERSION = "Layer4-V1.7";

// ==========================================================
// SoftAP
// ==========================================================

const char* AP_SSID = "Shuijing-Treasures";
const char* AP_PASSWORD = "12345678";

WebServer server(80);
Preferences prefs;

// ==========================================================
// STA Wi-Fi
// ==========================================================

String staSSID = "";
String staPassword = "";

bool restartScheduled = false;
unsigned long restartAt = 0;
unsigned long lastReconnectAttempt = 0;

// ==========================================================
// Public DNS
// ==========================================================

// 保留 DHCP IP,只覆寫 DNS server
IPAddress DNS1(8, 8, 8, 8);
IPAddress DNS2(1, 1, 1, 1);


// ==========================================================
// Django Cloud
// ==========================================================

const char* DEVICE_ID = "SHUIJING-001";

// 請改成 Django 中 SHUIJING-001 的 api_key
const char* DEVICE_KEY = "CHANGE-ME";

const char* CLOUD_HOST =
  "shuijingtreasures.pythonanywhere.com";

const char* CLOUD_EVENT_URL =
  "https://shuijingtreasures.pythonanywhere.com/api/exhibition/events/";

const char* CLOUD_HEARTBEAT_URL =
  "https://shuijingtreasures.pythonanywhere.com/api/exhibition/heartbeat/";

const char* EVENT_QUEUE_FILE = "/events.queue";
const char* EVENT_TEMP_FILE  = "/events.tmp";

// Cloud 策略
const unsigned long CLOUD_SYNC_INTERVAL_MS = 30000;    // 30 sec
const unsigned long HEARTBEAT_INTERVAL_MS = 120000;    // 120 sec
const unsigned long CLOUD_IDLE_GRACE_MS = 10000;       // 10 sec

const uint8_t MAX_EVENTS_PER_SYNC = 1;

unsigned long lastCloudSync = 0;
unsigned long lastHeartbeat = 0;
unsigned long lastLocalActivity = 0;
unsigned long eventSequence = 0;

// ==========================================================
// GPIO
// ==========================================================

#define JQ_TX_PIN       26
#define JQ_RX_PIN       27
#define JQ_BUSY_PIN     34

#define BTN_HORSE       32
#define BTN_TURTLE      33
#define BTN_FLOWER      25

#define TOF_SDA         21
#define TOF_SCL         22

#define LED_PIN         13
#define LED_COUNT       62

// ==========================================================
// LED Zones
// ==========================================================

#define BASE_START       0
#define BASE_COUNT      24

#define HORSE_START     24
#define HORSE_COUNT     10

#define TURTLE_START    34
#define TURTLE_COUNT    12

#define FLOWER_START    46
#define FLOWER_COUNT    16

// ==========================================================
// Parameters
// ==========================================================

const unsigned long DEBOUNCE_MS = 50;
const unsigned long COOLDOWN_MS = 2000;
const unsigned long BUSY_GUARD_MS = 300;
const unsigned long MAX_PLAY_MS = 180000;

const unsigned long TOF_INTERVAL_MS = 100;
const unsigned long PERSON_DWELL_MS = 1200;
const unsigned long ATTRACT_MS = 1500;

const int PERSON_DISTANCE_MM = 1200;

// 若實測播放時 BUSY=LOW,改成 false
const bool BUSY_ACTIVE_HIGH = true;

const bool USE_BUSY_CONFIRM = true;
const unsigned long JQ_START_CONFIRM_MS = 1000;
const unsigned long JQ_RETRY_CONFIRM_MS = 1500;

const unsigned long DIAG_INTERVAL_MS = 30000;

// ==========================================================
// Hardware
// ==========================================================

HardwareSerial JQ(2);
Adafruit_VL53L0X lox;

Adafruit_NeoPixel strip(
  LED_COUNT,
  LED_PIN,
  NEO_GRB + NEO_KHZ800
);

// ==========================================================
// State
// ==========================================================

enum SystemState
{
  STATE_IDLE,
  STATE_ATTRACT,
  STATE_READY,
  STATE_PLAYING,
  STATE_COOLDOWN,
  STATE_ERROR
};

enum Story
{
  STORY_NONE    = 0,
  STORY_HORSE   = 1,
  STORY_TURTLE  = 2,
  STORY_FLOWER  = 3,
  STORY_ALL     = 4,
  STORY_CREATOR = 5
};

SystemState state = STATE_IDLE;
Story currentStory = STORY_NONE;

// ==========================================================
// Button
// ==========================================================

struct Button
{
  uint8_t pin;
  bool stableState;
  bool lastReading;
  unsigned long lastChange;
  bool pressedEvent;
};

Button horseButton;
Button turtleButton;
Button flowerButton;

// ==========================================================
// Runtime
// ==========================================================

unsigned long stateStartTime = 0;
unsigned long playStartTime = 0;
unsigned long cooldownStartTime = 0;
unsigned long lastToFTime = 0;
unsigned long personStartTime = 0;

bool personDetected = false;
bool personTiming = false;
bool tofOK = false;
bool busySeenPlaying = false;
bool jqStartConfirmed = false;

int lastDistanceMM = -1;
uint8_t currentVolume = 20;

// ==========================================================
// Stats
// ==========================================================

struct Statistics
{
  unsigned long wakeCount = 0;

  unsigned long horseCount = 0;
  unsigned long turtleCount = 0;
  unsigned long flowerCount = 0;

  unsigned long totalCount = 0;
  unsigned long creatorCount = 0;

  unsigned long buttonCount = 0;
  unsigned long webCount = 0;

  unsigned long completedCount = 0;
  unsigned long stopCount = 0;

  unsigned long jqRetryCount = 0;
  unsigned long jqFailCount = 0;

  unsigned long cloudSuccessCount = 0;
  unsigned long cloudFailCount = 0;
};

Statistics stats;

// ==========================================================
// Names
// ==========================================================

String stateName()
{
  switch (state)
  {
    case STATE_IDLE:     return "IDLE";
    case STATE_ATTRACT:  return "ATTRACT";
    case STATE_READY:    return "READY";
    case STATE_PLAYING:  return "PLAYING";
    case STATE_COOLDOWN: return "COOLDOWN";
    case STATE_ERROR:    return "ERROR";
  }

  return "UNKNOWN";
}

String storyName(Story story)
{
  switch (story)
  {
    case STORY_HORSE:   return "白馬故事";
    case STORY_TURTLE:  return "烏龜故事";
    case STORY_FLOWER:  return "姻緣花故事";
    case STORY_ALL:     return "水井三寶總故事";
    case STORY_CREATOR: return "創作者資訊";
    default:            return "待機";
  }
}

String storyCode(Story story)
{
  switch (story)
  {
    case STORY_HORSE:   return "001";
    case STORY_TURTLE:  return "002";
    case STORY_FLOWER:  return "003";
    case STORY_ALL:     return "004";
    case STORY_CREATOR: return "005";
    default:            return "";
  }
}

// ==========================================================
// Local Activity
// ==========================================================

void markLocalActivity()
{
  lastLocalActivity = millis();
}

// ==========================================================
// Buttons
// ==========================================================

void initButton(Button &btn, uint8_t pin)
{
  btn.pin = pin;
  pinMode(pin, INPUT_PULLUP);

  btn.stableState = HIGH;
  btn.lastReading = HIGH;
  btn.lastChange = 0;
  btn.pressedEvent = false;
}

void updateButton(Button &btn)
{
  bool reading = digitalRead(btn.pin);

  if (reading != btn.lastReading)
  {
    btn.lastReading = reading;
    btn.lastChange = millis();
  }

  if (millis() - btn.lastChange >= DEBOUNCE_MS)
  {
    if (reading != btn.stableState)
    {
      btn.stableState = reading;

      if (btn.stableState == LOW)
      {
        btn.pressedEvent = true;
      }
    }
  }
}

void updateButtons()
{
  updateButton(horseButton);
  updateButton(turtleButton);
  updateButton(flowerButton);
}

void clearButtonEvents()
{
  horseButton.pressedEvent = false;
  turtleButton.pressedEvent = false;
  flowerButton.pressedEvent = false;
}

bool allButtonsReleased()
{
  return
    horseButton.stableState == HIGH &&
    turtleButton.stableState == HIGH &&
    flowerButton.stableState == HIGH;
}

// ==========================================================
// JQ6500
// ==========================================================

void sendJQ(const uint8_t *data, size_t length)
{
  JQ.write(data, length);
  JQ.flush();

  Serial.print("JQ TX: ");

  for (size_t i = 0; i < length; i++)
  {
    if (data[i] < 0x10)
    {
      Serial.print("0");
    }

    Serial.print(data[i], HEX);
    Serial.print(" ");
  }

  Serial.println();
}

void jqSelectFlash()
{
  uint8_t cmd[] =
  {
    0x7E,
    0x03,
    0x09,
    0x04,
    0xEF
  };

  sendJQ(cmd, sizeof(cmd));
}

void jqSetVolume(uint8_t volume)
{
  if (volume > 30)
  {
    volume = 30;
  }

  currentVolume = volume;

  uint8_t cmd[] =
  {
    0x7E,
    0x03,
    0x06,
    volume,
    0xEF
  };

  sendJQ(cmd, sizeof(cmd));
}

void jqPlayTrack(uint16_t track)
{
  uint8_t cmd[] =
  {
    0x7E,
    0x04,
    0x03,
    (uint8_t)(track >> 8),
    (uint8_t)(track & 0xFF),
    0xEF
  };

  sendJQ(cmd, sizeof(cmd));
}

void jqStop()
{
  uint8_t cmd[] =
  {
    0x7E,
    0x02,
    0x0E,
    0xEF
  };

  sendJQ(cmd, sizeof(cmd));
}

bool jqIsBusy()
{
  bool level = digitalRead(JQ_BUSY_PIN);

  return BUSY_ACTIVE_HIGH
    ? (level == HIGH)
    : (level == LOW);
}

bool waitJQStart(unsigned long timeoutMs)
{
  unsigned long start = millis();

  while (millis() - start < timeoutMs)
  {
    server.handleClient();

    if (jqIsBusy())
    {
      Serial.println(
        "JQ6500 PLAY CONFIRMED"
      );

      return true;
    }

    delay(10);
  }

  Serial.println(
    "JQ6500 PLAY NOT CONFIRMED"
  );

  return false;
}

bool startJQTrackWithRecovery(uint16_t track)
{
  Serial.println();
  Serial.println(
    "===== JQ6500 PLAY START ====="
  );

  Serial.print(
    "Track = "
  );

  Serial.println(track);

  Serial.println(
    "[1] First PLAY"
  );

  jqPlayTrack(track);

  delay(100);

  if (!USE_BUSY_CONFIRM)
  {
    return true;
  }

  if (waitJQStart(JQ_START_CONFIRM_MS))
  {
    Serial.println(
      "JQ6500 FIRST PLAY OK"
    );

    return true;
  }

  stats.jqRetryCount++;

  Serial.println();
  Serial.println(
    "[2] JQ6500 RECOVERY"
  );

  jqStop();
  delay(300);

  Serial.println(
    "Select Flash..."
  );

  jqSelectFlash();
  delay(700);

  Serial.print(
    "Set Volume = "
  );

  Serial.println(
    currentVolume
  );

  jqSetVolume(
    currentVolume
  );

  delay(500);

  Serial.println();
  Serial.println(
    "[3] Retry PLAY"
  );

  jqPlayTrack(track);

  delay(100);

  if (waitJQStart(JQ_RETRY_CONFIRM_MS))
  {
    Serial.println(
      "JQ6500 RETRY PLAY OK"
    );

    return true;
  }

  stats.jqFailCount++;

  Serial.println(
    "ERROR: JQ6500 PLAY FAILED"
  );

  return false;
}

// ==========================================================
// LED
// ==========================================================

void setZone(int start, int count, uint32_t color)
{
  for (int i = 0; i < count; i++)
  {
    strip.setPixelColor(
      start + i,
      color
    );
  }
}

void ledIdle()
{
  static unsigned long last = 0;
  static int b = 4;
  static int direction = 1;

  if (millis() - last < 80)
  {
    return;
  }

  last = millis();

  b += direction;

  if (b >= 14) direction = -1;
  if (b <= 4) direction = 1;

  strip.clear();

  setZone(
    BASE_START,
    BASE_COUNT,
    strip.Color(
      b,
      b / 2,
      0
    )
  );

  strip.show();
}

void ledAttract()
{
  static unsigned long last = 0;
  static int b = 8;
  static int direction = 1;

  if (millis() - last < 60)
  {
    return;
  }

  last = millis();

  b += direction;

  if (b >= 35) direction = -1;
  if (b <= 8) direction = 1;

  strip.clear();

  setZone(
    HORSE_START,
    HORSE_COUNT,
    strip.Color(
      b,
      b / 2,
      0
    )
  );

  setZone(
    TURTLE_START,
    TURTLE_COUNT,
    strip.Color(
      0,
      b / 2,
      b
    )
  );

  setZone(
    FLOWER_START,
    FLOWER_COUNT,
    strip.Color(
      b,
      0,
      b / 3
    )
  );

  strip.show();
}

void ledReady()
{
  strip.clear();

  setZone(
    HORSE_START,
    HORSE_COUNT,
    strip.Color(
      20,
      8,
      0
    )
  );

  setZone(
    TURTLE_START,
    TURTLE_COUNT,
    strip.Color(
      0,
      8,
      18
    )
  );

  setZone(
    FLOWER_START,
    FLOWER_COUNT,
    strip.Color(
      18,
      0,
      7
    )
  );

  strip.show();
}

void ledHorse()
{
  static unsigned long last = 0;
  static int pos = 0;

  if (millis() - last < 80)
  {
    return;
  }

  last = millis();

  strip.clear();

  for (int i = 0; i < HORSE_COUNT; i++)
  {
    int dist =
      (i - pos + HORSE_COUNT)
      % HORSE_COUNT;

    if (dist == 0)
    {
      strip.setPixelColor(
        HORSE_START + i,
        strip.Color(
          100,
          45,
          0
        )
      );
    }
    else if (dist <= 2)
    {
      strip.setPixelColor(
        HORSE_START + i,
        strip.Color(
          30,
          10,
          0
        )
      );
    }
  }

  pos++;

  if (pos >= HORSE_COUNT)
  {
    pos = 0;
  }

  strip.show();
}

void ledTurtle()
{
  static unsigned long last = 0;
  static int b = 10;
  static int direction = 1;

  if (millis() - last < 70)
  {
    return;
  }

  last = millis();

  b += direction;

  if (b >= 80) direction = -1;
  if (b <= 10) direction = 1;

  strip.clear();

  setZone(
    TURTLE_START,
    TURTLE_COUNT,
    strip.Color(
      0,
      b / 2,
      b
    )
  );

  strip.show();
}

void ledFlower()
{
  static unsigned long last = 0;
  static int count = 1;
  static bool growing = true;

  if (millis() - last < 120)
  {
    return;
  }

  last = millis();

  strip.clear();

  for (int i = 0; i < count; i++)
  {
    strip.setPixelColor(
      FLOWER_START + i,
      strip.Color(
        80,
        5,
        25
      )
    );
  }

  strip.show();

  if (growing)
  {
    count++;

    if (count >= FLOWER_COUNT)
    {
      count = FLOWER_COUNT;
      growing = false;
    }
  }
  else
  {
    count--;

    if (count <= 2)
    {
      count = 2;
      growing = true;
    }
  }
}

void ledAllStory()
{
  static unsigned long last = 0;
  static int phase = 0;

  if (millis() - last < 900)
  {
    return;
  }

  last = millis();

  phase++;

  if (phase > 2)
  {
    phase = 0;
  }

  strip.clear();

  if (phase == 0)
  {
    setZone(
      HORSE_START,
      HORSE_COUNT,
      strip.Color(
        70,
        30,
        0
      )
    );
  }
  else if (phase == 1)
  {
    setZone(
      TURTLE_START,
      TURTLE_COUNT,
      strip.Color(
        0,
        25,
        70
      )
    );
  }
  else
  {
    setZone(
      FLOWER_START,
      FLOWER_COUNT,
      strip.Color(
        70,
        5,
        25
      )
    );
  }

  strip.show();
}

void ledCreator()
{
  strip.clear();

  uint32_t warm =
    strip.Color(
      45,
      28,
      10
    );

  setZone(
    HORSE_START,
    HORSE_COUNT,
    warm
  );

  setZone(
    TURTLE_START,
    TURTLE_COUNT,
    warm
  );

  setZone(
    FLOWER_START,
    FLOWER_COUNT,
    warm
  );

  strip.show();
}

void updateStoryLED()
{
  switch (currentStory)
  {
    case STORY_HORSE:
      ledHorse();
      break;

    case STORY_TURTLE:
      ledTurtle();
      break;

    case STORY_FLOWER:
      ledFlower();
      break;

    case STORY_ALL:
      ledAllStory();
      break;

    case STORY_CREATOR:
      ledCreator();
      break;

    default:
      ledReady();
      break;
  }
}

// ==========================================================
// ToF
// ==========================================================

void updateToF()
{
  if (!tofOK)
  {
    return;
  }

  if (
    millis() - lastToFTime
    < TOF_INTERVAL_MS
  )
  {
    return;
  }

  lastToFTime = millis();

  VL53L0X_RangingMeasurementData_t
    measure;

  lox.rangingTest(
    &measure,
    false
  );

  bool nowDetected = false;

  if (measure.RangeStatus != 4)
  {
    lastDistanceMM =
      measure.RangeMilliMeter;

    if (
      lastDistanceMM > 50
      &&
      lastDistanceMM <= PERSON_DISTANCE_MM
    )
    {
      nowDetected = true;
    }
  }
  else
  {
    lastDistanceMM = -1;
  }

  if (nowDetected)
  {
    if (!personTiming)
    {
      personTiming = true;
      personStartTime = millis();
    }

    if (
      millis() - personStartTime
      >= PERSON_DWELL_MS
    )
    {
      if (!personDetected)
      {
        personDetected = true;
        stats.wakeCount++;
      }
    }
  }
  else
  {
    personTiming = false;
    personDetected = false;
  }
}

// ==========================================================
// Event Sequence
// ==========================================================

void loadEventSequence()
{
  prefs.begin(
    "event-seq",
    true
  );

  eventSequence =
    prefs.getULong(
      "seq",
      0
    );

  prefs.end();
}

unsigned long nextEventSequence()
{
  eventSequence++;

  prefs.begin(
    "event-seq",
    false
  );

  prefs.putULong(
    "seq",
    eventSequence
  );

  prefs.end();

  return eventSequence;
}

String makeEventUUID()
{
  char buf[80];

  snprintf(
    buf,
    sizeof(buf),
    "%s-%010lu",
    DEVICE_ID,
    nextEventSequence()
  );

  return String(buf);
}

// ==========================================================
// Event Queue
// ==========================================================

String buildEventJson(
  const String& eventType,
  Story story,
  const String& source,
  unsigned long durationMs,
  int completedValue
)
{
  String json;

  json.reserve(440);

  json += "{";

  json += "\"event_uuid\":\"";
  json += makeEventUUID();
  json += "\",";

  json += "\"event_type\":\"";
  json += eventType;
  json += "\",";

  json += "\"story\":\"";
  json += storyCode(story);
  json += "\",";

  json += "\"source\":\"";
  json += source;
  json += "\",";

  json += "\"duration_ms\":";
  json += String(durationMs);
  json += ",";

  json += "\"completed\":";

  if (completedValue < 0)
  {
    json += "null";
  }
  else
  {
    json +=
      completedValue
      ? "true"
      : "false";
  }

  json += ",";

  json += "\"network_status\":\"";

  json +=
    WiFi.status() == WL_CONNECTED
    ? "online"
    : "offline";

  json += "\",";

  json += "\"metadata\":{";

  json += "\"firmware\":\"";
  json += FIRMWARE_VERSION;
  json += "\",";

  json += "\"jq_confirmed\":";
  json +=
    jqStartConfirmed
    ? "true"
    : "false";

  json += "}";

  json += "}";

  return json;
}

bool appendQueuedEvent(
  const String& json
)
{
  File file =
    LittleFS.open(
      EVENT_QUEUE_FILE,
      FILE_APPEND
    );

  if (!file)
  {
    Serial.println(
      "ERROR: event queue open failed"
    );

    return false;
  }

  file.println(json);
  file.close();

  Serial.print(
    "EVENT QUEUED: "
  );

  Serial.println(json);

  return true;
}

void emitLocalEvent(
  const String& eventType,
  Story story,
  const String& source,
  unsigned long durationMs = 0,
  int completedValue = -1
)
{
  String json =
    buildEventJson(
      eventType,
      story,
      source,
      durationMs,
      completedValue
    );

  appendQueuedEvent(json);
}

size_t queueFileSize()
{
  if (
    !LittleFS.exists(
      EVENT_QUEUE_FILE
    )
  )
  {
    return 0;
  }

  File file =
    LittleFS.open(
      EVENT_QUEUE_FILE,
      FILE_READ
    );

  if (!file)
  {
    return 0;
  }

  size_t size =
    file.size();

  file.close();

  return size;
}

// ==========================================================
// Cloud Diagnostic V1.6
// DNS -> single TLS/HTTPS connection -> POST
// ==========================================================

bool postJsonToCloud(
  const char* url,
  const String& body
)
{
  Serial.println();
  Serial.println(
    "========== CLOUD POST =========="
  );

  // ------------------------------------------------------
  // 1. STA status
  // ------------------------------------------------------

  Serial.print(
    "WiFi status = "
  );

  Serial.println(
    WiFi.status()
  );

  if (
    WiFi.status() != WL_CONNECTED
  )
  {
    Serial.println(
      "ERROR: STA OFFLINE"
    );

    stats.cloudFailCount++;

    return false;
  }

  Serial.print(
    "STA IP = "
  );

  Serial.println(
    WiFi.localIP()
  );

  Serial.print(
    "Gateway = "
  );

  Serial.println(
    WiFi.gatewayIP()
  );

  Serial.print(
    "DNS = "
  );

  Serial.println(
    WiFi.dnsIP()
  );

  Serial.print(
    "RSSI = "
  );

  Serial.println(
    WiFi.RSSI()
  );

  // ------------------------------------------------------
  // 2. DNS
  // ------------------------------------------------------

  IPAddress serverIP;

  Serial.print(
    "Resolving: "
  );

  Serial.println(
    CLOUD_HOST
  );

  int dnsResult =
    WiFi.hostByName(
      CLOUD_HOST,
      serverIP
    );

  Serial.print(
    "DNS result = "
  );

  Serial.println(
    dnsResult
  );

  if (
    dnsResult != 1
  )
  {
    Serial.println(
      "ERROR: DNS FAILED"
    );

    stats.cloudFailCount++;

    return false;
  }

  Serial.print(
    "Resolved IP = "
  );

  Serial.println(
    serverIP
  );

  // ------------------------------------------------------
  // 3. Heap before TLS
  // ------------------------------------------------------

  Serial.print(
    "Free Heap before TLS = "
  );

  Serial.println(
    ESP.getFreeHeap()
  );

  // ------------------------------------------------------
  // 4. Single TLS client
  //    不再先做額外 testClient.connect()
  // ------------------------------------------------------

  WiFiClientSecure client;

  // 測試階段先略過 CA 驗證
  client.setInsecure();

  // 秒
  client.setHandshakeTimeout(
    20
  );

  // 毫秒
  client.setTimeout(
    15000
  );

  HTTPClient https;

  https.setConnectTimeout(
    15000
  );

  https.setTimeout(
    15000
  );

  https.setReuse(
    false
  );

  Serial.print(
    "POST URL = "
  );

  Serial.println(
    url
  );

  if (
    !https.begin(
      client,
      url
    )
  )
  {
    Serial.println(
      "ERROR: https.begin FAILED"
    );

    stats.cloudFailCount++;

    return false;
  }

  // ------------------------------------------------------
  // 5. Headers
  // ------------------------------------------------------

  https.addHeader(
    "Content-Type",
    "application/json"
  );

  https.addHeader(
    "X-Device-ID",
    DEVICE_ID
  );

  https.addHeader(
    "X-Device-Key",
    DEVICE_KEY
  );

  https.addHeader(
    "Connection",
    "close"
  );

  Serial.println(
    "POST body:"
  );

  Serial.println(
    body
  );

  // ------------------------------------------------------
  // 6. POST
  // ------------------------------------------------------

  unsigned long postStart =
    millis();

  int code =
    https.POST(
      body
    );

  unsigned long elapsed =
    millis() -
    postStart;

  Serial.print(
    "Cloud HTTP = "
  );

  Serial.println(
    code
  );

  Serial.print(
    "Elapsed = "
  );

  Serial.print(
    elapsed
  );

  Serial.println(
    " ms"
  );

  // ------------------------------------------------------
  // 7. Negative HTTPClient result
  // ------------------------------------------------------

  if (
    code < 0
  )
  {
    Serial.print(
      "HTTP error = "
    );

    Serial.println(
      HTTPClient::errorToString(
        code
      )
    );

    Serial.print(
      "Free Heap after fail = "
    );

    Serial.println(
      ESP.getFreeHeap()
    );

    char sslError[192] = {0};

    int tlsError =
      client.lastError(
        sslError,
        sizeof(sslError)
      );

    Serial.print(
      "TLS last error = "
    );

    Serial.println(
      tlsError
    );

    Serial.print(
      "TLS message = "
    );

    Serial.println(
      sslError
    );

    https.end();
    client.stop();

    stats.cloudFailCount++;

    return false;
  }

  // ------------------------------------------------------
  // 8. Server response
  // ------------------------------------------------------

  String response =
    https.getString();

  Serial.print(
    "Response = "
  );

  Serial.println(
    response
  );

  https.end();
  client.stop();

  Serial.print(
    "Free Heap after POST = "
  );

  Serial.println(
    ESP.getFreeHeap()
  );

  // ------------------------------------------------------
  // 9. Result
  // ------------------------------------------------------

  if (
    code >= 200
    &&
    code < 300
  )
  {
    stats.cloudSuccessCount++;

    Serial.println(
      "CLOUD POST OK"
    );

    return true;
  }

  stats.cloudFailCount++;

  Serial.println(
    "CLOUD POST FAILED"
  );

  return false;
}

// ==========================================================
// Cloud Sync Rule
// ==========================================================

bool cloudSyncAllowed()
{
  if (
    state == STATE_PLAYING
  )
  {
    return false;
  }

  if (
    state == STATE_COOLDOWN
  )
  {
    return false;
  }

  if (
    state == STATE_ATTRACT
  )
  {
    return false;
  }

  // READY / IDLE 都可同步
  if (
    state != STATE_IDLE
    &&
    state != STATE_READY
  )
  {
    return false;
  }

  if (
    WiFi.status() != WL_CONNECTED
  )
  {
    return false;
  }

  if (
    millis() - lastLocalActivity
    < CLOUD_IDLE_GRACE_MS
  )
  {
    return false;
  }

  return true;
}

void printCloudStatus()
{
  Serial.print(
    "CLOUD | "
  );

  if (
    WiFi.status() != WL_CONNECTED
  )
  {
    Serial.println(
      "BLOCKED: STA OFFLINE"
    );

    return;
  }

  if (
    state == STATE_PLAYING
  )
  {
    Serial.println(
      "BLOCKED: PLAYING"
    );

    return;
  }

  if (
    state == STATE_COOLDOWN
  )
  {
    Serial.println(
      "BLOCKED: COOLDOWN"
    );

    return;
  }

  if (
    state == STATE_ATTRACT
  )
  {
    Serial.println(
      "BLOCKED: ATTRACT"
    );

    return;
  }

  unsigned long elapsed =
    millis() - lastLocalActivity;

  if (
    elapsed < CLOUD_IDLE_GRACE_MS
  )
  {
    Serial.print(
      "BLOCKED: LOCAL ACTIVITY, wait "
    );

    Serial.print(
      (CLOUD_IDLE_GRACE_MS - elapsed)
      / 1000
    );

    Serial.println(
      " sec"
    );

    return;
  }

  Serial.print(
    "READY | Queue="
  );

  Serial.print(
    queueFileSize()
  );

  Serial.println(
    " bytes"
  );
}

// ==========================================================
// Queue Sync
// ==========================================================

void copyRemainingLines(
  File &input,
  File &output
)
{
  while (input.available())
  {
    String line =
      input.readStringUntil('\n');

    line.trim();

    if (line.length() > 0)
    {
      output.println(line);
    }
  }
}

void syncQueuedEvents()
{
  if (!cloudSyncAllowed())
  {
    return;
  }

  if (
    !LittleFS.exists(
      EVENT_QUEUE_FILE
    )
  )
  {
    return;
  }

  File input =
    LittleFS.open(
      EVENT_QUEUE_FILE,
      FILE_READ
    );

  if (!input)
  {
    return;
  }

  LittleFS.remove(
    EVENT_TEMP_FILE
  );

  File output =
    LittleFS.open(
      EVENT_TEMP_FILE,
      FILE_WRITE
    );

  if (!output)
  {
    input.close();
    return;
  }

  uint8_t sentThisPass = 0;

  while (input.available())
  {
    String line =
      input.readStringUntil('\n');

    line.trim();

    if (line.length() == 0)
    {
      continue;
    }

    if (
      sentThisPass >=
      MAX_EVENTS_PER_SYNC
    )
    {
      output.println(line);

      copyRemainingLines(
        input,
        output
      );

      break;
    }

    if (!cloudSyncAllowed())
    {
      output.println(line);

      copyRemainingLines(
        input,
        output
      );

      break;
    }

    Serial.println(
      "Cloud sync event..."
    );

    if (
      postJsonToCloud(
        CLOUD_EVENT_URL,
        line
      )
    )
    {
      sentThisPass++;

      Serial.println(
        "Cloud sync OK"
      );
    }
    else
    {
      Serial.println(
        "Cloud sync FAILED"
      );

      output.println(line);

      copyRemainingLines(
        input,
        output
      );

      break;
    }

    delay(20);
  }

  input.close();
  output.close();

  LittleFS.remove(
    EVENT_QUEUE_FILE
  );

  File check =
    LittleFS.open(
      EVENT_TEMP_FILE,
      FILE_READ
    );

  size_t remainingSize = 0;

  if (check)
  {
    remainingSize =
      check.size();

    check.close();
  }

  if (remainingSize > 0)
  {
    LittleFS.rename(
      EVENT_TEMP_FILE,
      EVENT_QUEUE_FILE
    );
  }
  else
  {
    LittleFS.remove(
      EVENT_TEMP_FILE
    );
  }
}

// ==========================================================
// Heartbeat
// ==========================================================

void sendHeartbeat()
{
  if (!cloudSyncAllowed())
  {
    return;
  }

  String body;

  body.reserve(240);

  body += "{";

  body += "\"state\":\"";
  body += stateName();
  body += "\",";

  body += "\"firmware\":\"";
  body += FIRMWARE_VERSION;
  body += "\",";

  body += "\"rssi\":";
  body += String(WiFi.RSSI());

  body += "}";

  Serial.println(
    "Sending heartbeat..."
  );

  postJsonToCloud(
    CLOUD_HEARTBEAT_URL,
    body
  );
}

// ==========================================================
// Start Story
// ==========================================================

void startStory(
  Story story,
  bool fromWeb
)
{
  Serial.println();
  Serial.println(
    "========== START STORY =========="
  );

  Serial.print(
    "story = "
  );

  Serial.println(
    (int)story
  );

  Serial.print(
    "source = "
  );

  Serial.println(
    fromWeb
    ? "WEB"
    : "BUTTON"
  );

  Serial.print(
    "state = "
  );

  Serial.println(
    stateName()
  );

  if (
    state == STATE_PLAYING
    ||
    state == STATE_COOLDOWN
  )
  {
    Serial.println(
      "START STORY rejected"
    );

    return;
  }

  markLocalActivity();

  currentStory = story;
  busySeenPlaying = false;
  jqStartConfirmed = false;

  clearButtonEvents();

  uint16_t track = 0;

  switch (story)
  {
    case STORY_HORSE:

      track = 1;
      stats.horseCount++;

      Serial.println(
        "PLAY 001 HORSE"
      );

      break;

    case STORY_TURTLE:

      track = 2;
      stats.turtleCount++;

      Serial.println(
        "PLAY 002 TURTLE"
      );

      break;

    case STORY_FLOWER:

      track = 3;
      stats.flowerCount++;

      Serial.println(
        "PLAY 003 FLOWER"
      );

      break;

    case STORY_ALL:

      track = 4;
      stats.totalCount++;

      Serial.println(
        "PLAY 004 ALL"
      );

      break;

    case STORY_CREATOR:

      track = 5;
      stats.creatorCount++;

      Serial.println(
        "PLAY 005 CREATOR"
      );

      break;

    default:

      Serial.println(
        "INVALID STORY"
      );

      return;
  }

  jqStartConfirmed =
    startJQTrackWithRecovery(
      track
    );

  if (!jqStartConfirmed)
  {
    Serial.println(
      "STORY ABORTED: JQ6500 NOT PLAYING"
    );

    emitLocalEvent(
      "DEVICE_ERROR",
      story,
      "system",
      0,
      0
    );

    currentStory =
      STORY_NONE;

    state =
      personDetected
      ? STATE_READY
      : STATE_IDLE;

    return;
  }

  busySeenPlaying = true;

  if (fromWeb)
  {
    stats.webCount++;
  }
  else
  {
    stats.buttonCount++;
  }

  playStartTime =
    millis();

  state =
    STATE_PLAYING;

  emitLocalEvent(
    "STORY_START",
    story,
    fromWeb
      ? "web"
      : "button",
    0,
    -1
  );

  Serial.println(
    "STATE -> PLAYING"
  );

  Serial.println(
    "================================="
  );
}

// ==========================================================
// Buttons
// ==========================================================

void handleButtons()
{
  if (horseButton.pressedEvent)
  {
    startStory(
      STORY_HORSE,
      false
    );

    return;
  }

  if (turtleButton.pressedEvent)
  {
    startStory(
      STORY_TURTLE,
      false
    );

    return;
  }

  if (flowerButton.pressedEvent)
  {
    startStory(
      STORY_FLOWER,
      false
    );

    return;
  }
}

// ==========================================================
// State Machine
// ==========================================================

void runStateMachine()
{
  switch (state)
  {
    case STATE_IDLE:

      ledIdle();
      handleButtons();

      if (
        state != STATE_PLAYING
        &&
        personDetected
      )
      {
        state =
          STATE_ATTRACT;

        stateStartTime =
          millis();

        markLocalActivity();

        emitLocalEvent(
          "EXHIBIT_WAKE",
          STORY_NONE,
          "system",
          0,
          -1
        );
      }

      break;

    case STATE_ATTRACT:

      ledAttract();
      handleButtons();

      if (
        state != STATE_PLAYING
        &&
        millis() - stateStartTime
        >= ATTRACT_MS
      )
      {
        state =
          STATE_READY;
      }

      break;

    case STATE_READY:

      ledReady();
      handleButtons();

      if (
        state != STATE_PLAYING
        &&
        !personDetected
      )
      {
        state =
          STATE_IDLE;
      }

      break;

    case STATE_PLAYING:

      clearButtonEvents();

      updateStoryLED();

      if (
        millis() - playStartTime
        < BUSY_GUARD_MS
      )
      {
        break;
      }

      if (jqIsBusy())
      {
        busySeenPlaying = true;
      }

      if (
        busySeenPlaying
        &&
        !jqIsBusy()
      )
      {
        Serial.println(
          "PLAY COMPLETE"
        );

        unsigned long duration =
          millis() - playStartTime;

        stats.completedCount++;

        markLocalActivity();

        emitLocalEvent(
          "STORY_COMPLETE",
          currentStory,
          "system",
          duration,
          1
        );

        cooldownStartTime =
          millis();

        state =
          STATE_COOLDOWN;
      }

      if (
        millis() - playStartTime
        > MAX_PLAY_MS
      )
      {
        Serial.println(
          "PLAY TIMEOUT"
        );

        jqStop();

        unsigned long duration =
          millis() - playStartTime;

        markLocalActivity();

        emitLocalEvent(
          "PLAY_TIMEOUT",
          currentStory,
          "system",
          duration,
          0
        );

        cooldownStartTime =
          millis();

        state =
          STATE_COOLDOWN;
      }

      break;

    case STATE_COOLDOWN:

      clearButtonEvents();
      ledReady();

      if (
        millis() - cooldownStartTime
        >= COOLDOWN_MS
        &&
        allButtonsReleased()
      )
      {
        currentStory =
          STORY_NONE;

        state =
          personDetected
          ? STATE_READY
          : STATE_IDLE;
      }

      break;

    case STATE_ERROR:

      strip.clear();
      strip.show();

      break;
  }
}

// ==========================================================
// Wi-Fi NVS
// ==========================================================

void loadWiFiSettings()
{
  prefs.begin(
    "wifi-config",
    true
  );

  staSSID =
    prefs.getString(
      "ssid",
      ""
    );

  staPassword =
    prefs.getString(
      "password",
      ""
    );

  prefs.end();

  Serial.println();
  Serial.println(
    "========== NVS Wi-Fi =========="
  );

  Serial.print(
    "SSID: "
  );

  if (staSSID.length())
  {
    Serial.println(
      staSSID
    );
  }
  else
  {
    Serial.println(
      "(not configured)"
    );
  }

  Serial.println(
    "================================"
  );
}

void connectSTA()
{
  if (
    staSSID.length() == 0
  )
  {
    Serial.println(
      "STA: no saved Wi-Fi"
    );

    return;
  }

  Serial.println();
  Serial.println(
    "========== STA CONNECT =========="
  );

  Serial.print(
    "Connecting STA: "
  );

  Serial.println(
    staSSID
  );

  WiFi.begin(
    staSSID.c_str(),
    staPassword.c_str()
  );

  unsigned long start =
    millis();

  while (
    WiFi.status() != WL_CONNECTED
    &&
    millis() - start < 15000
  )
  {
    Serial.print(".");
    delay(500);
  }

  Serial.println();

  if (
    WiFi.status() != WL_CONNECTED
  )
  {
    Serial.println(
      "STA CONNECT FAILED"
    );

    Serial.println(
      "SoftAP remains available."
    );

    return;
  }

  Serial.println(
    "STA CONNECTED"
  );

  Serial.print(
    "STA IP = "
  );

  Serial.println(
    WiFi.localIP()
  );

  Serial.print(
    "Gateway = "
  );

  Serial.println(
    WiFi.gatewayIP()
  );

  Serial.print(
    "DHCP DNS = "
  );

  Serial.println(
    WiFi.dnsIP(0)
  );

  // ------------------------------------------------------
  // Public DNS Fix
  // ------------------------------------------------------

  bool dnsOK =
    WiFi.setDNS(
      DNS1,
      DNS2
    );

  Serial.print(
    "Set Public DNS = "
  );

  Serial.println(
    dnsOK
    ? "OK"
    : "FAILED"
  );

  delay(200);

  Serial.print(
    "DNS1 = "
  );

  Serial.println(
    WiFi.dnsIP(0)
  );

  Serial.print(
    "DNS2 = "
  );

  Serial.println(
    WiFi.dnsIP(1)
  );

  // ------------------------------------------------------
  // DNS self-test
  // ------------------------------------------------------

  IPAddress testIP;

  int dnsResult =
    WiFi.hostByName(
      CLOUD_HOST,
      testIP
    );

  Serial.print(
    "DNS self-test = "
  );

  Serial.println(
    dnsResult
  );

  if (dnsResult == 1)
  {
    Serial.print(
      "Resolved = "
    );

    Serial.println(
      testIP
    );
  }
  else
  {
    Serial.println(
      "WARNING: DNS still failed."
    );
  }

  Serial.println(
    "================================="
  );
}

void maintainSTA()
{
  if (
    staSSID.length() == 0
  )
  {
    return;
  }

  if (
    WiFi.status() != WL_CONNECTED
    &&
    millis() - lastReconnectAttempt > 15000
  )
  {
    // 播放期間不主動重連
    if (
      state == STATE_PLAYING
    )
    {
      return;
    }

    lastReconnectAttempt =
      millis();

    Serial.println(
      "STA reconnect..."
    );

    WiFi.begin(
      staSSID.c_str(),
      staPassword.c_str()
    );

    unsigned long start =
      millis();

    while (
      WiFi.status() != WL_CONNECTED
      &&
      millis() - start < 10000
    )
    {
      server.handleClient();
      delay(250);
    }

    if (
      WiFi.status() == WL_CONNECTED
    )
    {
      WiFi.setDNS(
        DNS1,
        DNS2
      );

      Serial.print(
        "STA reconnected | IP="
      );

      Serial.print(
        WiFi.localIP()
      );

      Serial.print(
        " | DNS="
      );

      Serial.println(
        WiFi.dnsIP(0)
      );
    }
    else
    {
      Serial.println(
        "STA reconnect failed"
      );
    }
  }
}

// ==========================================================
// HTML
// ==========================================================

String htmlHeader(String title)
{
  String s;

  s.reserve(3800);

  s +=
    "<!DOCTYPE html>"
    "<html lang='zh-TW'>"
    "<head>"
    "<meta charset='UTF-8'>"
    "<meta name='viewport' content='width=device-width,initial-scale=1'>";

  s +=
    "<title>" +
    title +
    "</title>";

  s +=
    "<style>"

    "body{margin:0;background:#f4f0e6;"
    "font-family:Arial,'Microsoft JhengHei',sans-serif;color:#333}"

    ".wrap{max-width:760px;margin:auto;padding:16px}"

    ".hero{background:#654321;color:white;padding:20px;border-radius:16px}"

    ".card{background:white;margin-top:14px;padding:16px;border-radius:14px;"
    "box-shadow:0 3px 12px #0002}"

    ".grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}"

    ".btn,button{display:block;width:100%;box-sizing:border-box;"
    "text-align:center;border:0;border-radius:10px;padding:14px 8px;"
    "font-size:16px;text-decoration:none;color:#222;background:#ddd}"

    ".horse{background:#f5c96b}"
    ".turtle{background:#81d4fa}"
    ".flower{background:#f48fb1}"
    ".all{background:#a5d6a7}"
    ".creator{background:#d7ccc8}"
    ".stop{background:#c62828;color:white}"
    ".cloud{background:#90caf9}"

    "input{width:100%;box-sizing:border-box;padding:12px;"
    "margin:6px 0 14px;border:1px solid #aaa;border-radius:8px}"

    ".ok{color:#2e7d32;font-weight:bold}"
    ".bad{color:#c62828;font-weight:bold}"
    ".note{font-size:13px;color:#666}"

    "</style>"
    "</head>"
    "<body>"
    "<div class='wrap'>"
    "<div class='hero'>"
    "<h1>🌳 水井三寶智慧互動展覽系統</h1>"
    "<div>Layer 4 V1.7|Public DNS Fix</div>"
    "</div>";

  return s;
}

// ==========================================================
// Root
// ==========================================================

void handleRoot()
{
  markLocalActivity();

  String s =
    htmlHeader(
      "水井三寶"
    );

  s +=
    "<div class='card'>"
    "<h2>系統狀態</h2>";

  s +=
    "<p>State:<b>" +
    stateName() +
    "</b></p>";

  s +=
    "<p>Story:<b>" +
    storyName(currentStory) +
    "</b></p>";

  s +=
    "<p>ToF:" +
    String(lastDistanceMM) +
    " mm</p>";

  s +=
    "<p>AP:<b>192.168.4.1</b></p>";

  s += "<p>STA:";

  if (
    WiFi.status() ==
    WL_CONNECTED
  )
  {
    s +=
      "<span class='ok'>ONLINE</span> / ";

    s +=
      WiFi.localIP().toString();

    s +=
      " / RSSI ";

    s +=
      String(WiFi.RSSI());

    s +=
      " dBm";
  }
  else
  {
    s +=
      "<span class='bad'>OFFLINE</span>";
  }

  s += "</p>";

  s +=
    "<p>DNS:<b>" +
    WiFi.dnsIP(0).toString() +
    "</b></p>";

  s +=
    "<p>Queue:<b>" +
    String(queueFileSize()) +
    " bytes</b></p>";

  s +=
    "<div class='grid'>"
    "<a class='btn' href='http://192.168.4.1/wifi'>⚙ Wi-Fi 設定</a>"
    "<a class='btn cloud' href='http://192.168.4.1/cloudtest'>☁ Cloud Test</a>"
    "</div>";

  s += "</div>";

  // Stories
  s +=
    "<div class='card'>"
    "<h2>🎵 故事控制</h2>"
    "<div class='grid'>";

  s +=
    "<a class='btn horse' href='http://192.168.4.1/play?track=1'>"
    "🐴 001 白馬故事</a>";

  s +=
    "<a class='btn turtle' href='http://192.168.4.1/play?track=2'>"
    "🐢 002 烏龜故事</a>";

  s +=
    "<a class='btn flower' href='http://192.168.4.1/play?track=3'>"
    "🌸 003 姻緣花故事</a>";

  s +=
    "<a class='btn all' href='http://192.168.4.1/play?track=4'>"
    "🌳 004 水井三寶總故事</a>";

  s +=
    "<a class='btn creator' href='http://192.168.4.1/play?track=5'>"
    "👨‍🎨 005 創作者資訊</a>";

  s +=
    "<a class='btn stop' href='http://192.168.4.1/stop'>"
    "■ STOP</a>";

  s +=
    "</div>"
    "</div>";

  // Diagnostics
  s +=
    "<div class='card'>"
    "<h2>🔧 設備診斷</h2>";

  s +=
    "<p>JQ BUSY:<b>";

  s +=
    jqIsBusy()
    ? "ACTIVE"
    : "IDLE";

  s += "</b></p>";

  s +=
    "<p>JQ Start Confirmed:<b>";

  s +=
    jqStartConfirmed
    ? "YES"
    : "NO";

  s += "</b></p>";

  s +=
    "<p>JQ Retry:" +
    String(stats.jqRetryCount) +
    "</p>";

  s +=
    "<p>JQ Fail:" +
    String(stats.jqFailCount) +
    "</p>";

  s +=
    "<p>Cloud OK:" +
    String(stats.cloudSuccessCount) +
    "</p>";

  s +=
    "<p>Cloud Fail:" +
    String(stats.cloudFailCount) +
    "</p>";

  s += "</div>";

  // Stats
  s +=
    "<div class='card'>"
    "<h2>📊 即時匿名統計</h2>";

  s += "<p>白馬:" + String(stats.horseCount) + "</p>";
  s += "<p>烏龜:" + String(stats.turtleCount) + "</p>";
  s += "<p>姻緣花:" + String(stats.flowerCount) + "</p>";
  s += "<p>004:" + String(stats.totalCount) + "</p>";
  s += "<p>005:" + String(stats.creatorCount) + "</p>";
  s += "<p>Web:" + String(stats.webCount) + "</p>";
  s += "<p>實體按鈕:" + String(stats.buttonCount) + "</p>";
  s += "<p>展區喚醒:" + String(stats.wakeCount) + "</p>";
  s += "<p>完整播放:" + String(stats.completedCount) + "</p>";

  s += "</div>";

  s +=
    "</div>"
    "</body>"
    "</html>";

  server.send(
    200,
    "text/html; charset=utf-8",
    s
  );
}

// ==========================================================
// Wi-Fi Page
// ==========================================================

void handleWiFiPage()
{
  markLocalActivity();

  String s =
    htmlHeader(
      "Wi-Fi 設定"
    );

  s +=
    "<div class='card'>"
    "<h2>Internet STA 設定</h2>";

  s +=
    "<p>本地 AP 永遠維持:"
    "<b>Shuijing-Treasures</b> / 192.168.4.1</p>";

  s +=
    "<p>目前 SSID:<b>";

  if (staSSID.length())
  {
    s += staSSID;
  }
  else
  {
    s += "尚未設定";
  }

  s += "</b></p>";

  s +=
    "<form method='GET' action='http://192.168.4.1/save'>";

  s +=
    "<label>Wi-Fi SSID</label>"
    "<input name='ssid' maxlength='32' required value='";

  s += staSSID;

  s += "'>";

  s +=
    "<label>Wi-Fi Password</label>"
    "<input type='password' name='password' maxlength='64'>";

  s +=
    "<button type='submit'>儲存並重新啟動</button>"
    "</form>";

  s +=
    "<p><a class='btn' href='http://192.168.4.1/clearwifi'>"
    "清除 STA 設定</a></p>";

  s +=
    "<p><a class='btn' href='http://192.168.4.1/'>"
    "回控制頁</a></p>";

  s +=
    "</div>"
    "</div>"
    "</body>"
    "</html>";

  server.send(
    200,
    "text/html; charset=utf-8",
    s
  );
}

// ==========================================================
// Save Wi-Fi
// ==========================================================

void handleConfigSave()
{
  markLocalActivity();

  if (!server.hasArg("ssid"))
  {
    server.send(
      400,
      "text/plain",
      "SSID missing"
    );

    return;
  }

  String newSSID =
    server.arg("ssid");

  String newPassword =
    server.arg("password");

  if (
    newSSID.length() == 0
  )
  {
    server.send(
      400,
      "text/plain",
      "SSID empty"
    );

    return;
  }

  if (
    newSSID == staSSID
    &&
    newPassword.length() == 0
  )
  {
    newPassword =
      staPassword;
  }

  prefs.begin(
    "wifi-config",
    false
  );

  prefs.putString(
    "ssid",
    newSSID
  );

  prefs.putString(
    "password",
    newPassword
  );

  prefs.end();

  prefs.begin(
    "wifi-config",
    true
  );

  String verifySSID =
    prefs.getString(
      "ssid",
      ""
    );

  String verifyPassword =
    prefs.getString(
      "password",
      ""
    );

  prefs.end();

  bool ok =
    verifySSID == newSSID
    &&
    verifyPassword == newPassword;

  if (!ok)
  {
    server.send(
      500,
      "text/plain",
      "NVS ERROR"
    );

    return;
  }

  staSSID = verifySSID;
  staPassword = verifyPassword;

  String s =
    htmlHeader(
      "Wi-Fi 設定完成"
    );

  s +=
    "<div class='card'>"
    "<h2>✅ Wi-Fi 設定完成</h2>"
    "<p>SSID:<b>";

  s += staSSID;

  s +=
    "</b></p>"
    "<p>設定已寫入 NVS。</p>"
    "<p>ESP32 將於 5 秒後重新啟動。</p>"
    "</div>"
    "</div>"
    "</body>"
    "</html>";

  server.send(
    200,
    "text/html; charset=utf-8",
    s
  );

  restartScheduled = true;
  restartAt = millis() + 5000;
}

// ==========================================================
// Clear Wi-Fi
// ==========================================================

void handleClearWiFi()
{
  markLocalActivity();

  prefs.begin(
    "wifi-config",
    false
  );

  prefs.clear();
  prefs.end();

  staSSID = "";
  staPassword = "";

  WiFi.disconnect();

  server.send(
    200,
    "text/html; charset=utf-8",
    "<html><meta charset='UTF-8'><body>"
    "<h2>STA Wi-Fi 已清除</h2>"
    "<p><a href='http://192.168.4.1/wifi'>重新設定</a></p>"
    "</body></html>"
  );
}

// ==========================================================
// Cloud Test
// ==========================================================

void handleCloudTest()
{
  markLocalActivity();

  String json =
    buildEventJson(
      "DEVICE_BOOT",
      STORY_NONE,
      "system",
      0,
      -1
    );

  Serial.println();
  Serial.println(
    "========== CLOUD TEST =========="
  );

  bool ok =
    postJsonToCloud(
      CLOUD_EVENT_URL,
      json
    );

  String s =
    htmlHeader(
      "Cloud Test"
    );

  s +=
    "<div class='card'>";

  if (ok)
  {
    s +=
      "<h2>✅ Cloud Test OK</h2>"
      "<p>Django 已接受事件。</p>";
  }
  else
  {
    s +=
      "<h2>❌ Cloud Test FAILED</h2>"
      "<p>請查看 Serial Monitor 的 DNS / TLS / HTTP 診斷資訊。</p>";
  }

  s +=
    "<p><a class='btn' href='http://192.168.4.1/'>回控制頁</a></p>"
    "</div>"
    "</div>"
    "</body>"
    "</html>";

  server.send(
    200,
    "text/html; charset=utf-8",
    s
  );
}

// ==========================================================
// Web Play
// ==========================================================

void handlePlay()
{
  markLocalActivity();

  if (!server.hasArg("track"))
  {
    server.send(
      400,
      "text/plain",
      "track missing"
    );

    return;
  }

  int track =
    server.arg("track")
      .toInt();

  if (
    track < 1
    ||
    track > 5
  )
  {
    server.send(
      400,
      "text/plain",
      "invalid track"
    );

    return;
  }

  if (
    state == STATE_PLAYING
    ||
    state == STATE_COOLDOWN
  )
  {
    server.sendHeader(
      "Location",
      "http://192.168.4.1/"
    );

    server.send(
      303,
      "text/plain",
      ""
    );

    return;
  }

  startStory(
    (Story)track,
    true
  );

  server.sendHeader(
    "Location",
    "http://192.168.4.1/"
  );

  server.send(
    303,
    "text/plain",
    ""
  );
}

// ==========================================================
// Stop
// ==========================================================

void handleStop()
{
  markLocalActivity();

  unsigned long duration = 0;

  if (
    state == STATE_PLAYING
  )
  {
    duration =
      millis() - playStartTime;
  }

  jqStop();

  stats.stopCount++;

  emitLocalEvent(
    "STOP",
    currentStory,
    "web",
    duration,
    0
  );

  currentStory =
    STORY_NONE;

  cooldownStartTime =
    millis();

  state =
    STATE_COOLDOWN;

  server.sendHeader(
    "Location",
    "http://192.168.4.1/"
  );

  server.send(
    303,
    "text/plain",
    ""
  );
}

// ==========================================================
// Volume
// ==========================================================

void handleVolume()
{
  markLocalActivity();

  if (!server.hasArg("v"))
  {
    server.send(
      400,
      "text/plain",
      "volume missing"
    );

    return;
  }

  int volume =
    server.arg("v")
      .toInt();

  if (volume < 0) volume = 0;
  if (volume > 30) volume = 30;

  jqSetVolume(
    (uint8_t)volume
  );

  server.sendHeader(
    "Location",
    "http://192.168.4.1/"
  );

  server.send(
    303,
    "text/plain",
    ""
  );
}

// ==========================================================
// 404
// ==========================================================

void handleNotFound()
{
  markLocalActivity();

  server.send(
    404,
    "text/plain",
    "Not Found"
  );
}

// ==========================================================
// Web Setup
// ==========================================================

void setupWeb()
{
  server.on(
    "/",
    HTTP_GET,
    handleRoot
  );

  server.on(
    "/wifi",
    HTTP_GET,
    handleWiFiPage
  );

  server.on(
    "/save",
    HTTP_ANY,
    handleConfigSave
  );

  server.on(
    "/clearwifi",
    HTTP_GET,
    handleClearWiFi
  );

  server.on(
    "/cloudtest",
    HTTP_GET,
    handleCloudTest
  );

  server.on(
    "/play",
    HTTP_GET,
    handlePlay
  );

  server.on(
    "/stop",
    HTTP_GET,
    handleStop
  );

  server.on(
    "/volume",
    HTTP_GET,
    handleVolume
  );

  server.onNotFound(
    handleNotFound
  );

  server.begin();
}

// ==========================================================
// Setup
// ==========================================================

void setup()
{
  Serial.begin(115200);

  delay(1000);

  Serial.println();
  Serial.println(
    "=============================================="
  );
  Serial.println(
    "Shuijing Treasures Layer 4 V1.7"
  );
  Serial.println(
    "CLOUD DIAGNOSTIC"
  );
  Serial.println(
    "=============================================="
  );

  initButton(
    horseButton,
    BTN_HORSE
  );

  initButton(
    turtleButton,
    BTN_TURTLE
  );

  initButton(
    flowerButton,
    BTN_FLOWER
  );

  pinMode(
    JQ_BUSY_PIN,
    INPUT
  );

  strip.begin();
  strip.setBrightness(100);
  strip.clear();
  strip.show();

  JQ.begin(
    9600,
    SERIAL_8N1,
    JQ_RX_PIN,
    JQ_TX_PIN
  );

  delay(3000);

  jqSelectFlash();

  delay(2500);

  jqSetVolume(
    currentVolume
  );

  Wire.begin(
    TOF_SDA,
    TOF_SCL
  );

  tofOK =
    lox.begin();

  Serial.println(
    tofOK
    ? "VL53L0X OK"
    : "VL53L0X ERROR"
  );

  if (
    LittleFS.begin(true)
  )
  {
    Serial.println(
      "LittleFS OK"
    );
  }
  else
  {
    Serial.println(
      "LittleFS ERROR"
    );
  }

  loadEventSequence();

  Serial.println(
    "Starting SoftAP..."
  );

  if (
    !WiFi.softAP(
      AP_SSID,
      AP_PASSWORD
    )
  )
  {
    Serial.println(
      "SoftAP FAILED"
    );

    while (true)
    {
      delay(1000);
    }
  }

  Serial.print(
    "AP IP = "
  );

  Serial.println(
    WiFi.softAPIP()
  );

  loadWiFiSettings();

  connectSTA();

  setupWeb();

  state =
    STATE_IDLE;

  markLocalActivity();

  emitLocalEvent(
    "DEVICE_BOOT",
    STORY_NONE,
    "system",
    0,
    -1
  );

  Serial.println();
  Serial.println(
    "SYSTEM READY"
  );

  Serial.println(
    "Control: http://192.168.4.1"
  );

  Serial.println(
    "Wi-Fi Setup: http://192.168.4.1/wifi"
  );

  Serial.println(
    "Cloud Test: http://192.168.4.1/cloudtest"
  );

  Serial.print(
    "Active DNS: "
  );

  Serial.println(
    WiFi.dnsIP(0)
  );
}

// ==========================================================
// Loop
// ==========================================================

void loop()
{
  server.handleClient();

  updateButtons();

  updateToF();

  runStateMachine();

  maintainSTA();

  if (
    restartScheduled
    &&
    (long)(
      millis() -
      restartAt
    ) >= 0
  )
  {
    server.stop();
    delay(300);
    ESP.restart();
  }

  if (JQ.available())
  {
    Serial.print(
      "JQ RX: "
    );

    while (JQ.available())
    {
      uint8_t b =
        JQ.read();

      if (b < 0x10)
      {
        Serial.print("0");
      }

      Serial.print(
        b,
        HEX
      );

      Serial.print(" ");
    }

    Serial.println();
  }

  // Cloud sync
  if (
    millis() - lastCloudSync
    >= CLOUD_SYNC_INTERVAL_MS
  )
  {
    lastCloudSync =
      millis();

    if (cloudSyncAllowed())
    {
      syncQueuedEvents();
    }
  }

  // Heartbeat
  if (
    millis() - lastHeartbeat
    >= HEARTBEAT_INTERVAL_MS
  )
  {
    lastHeartbeat =
      millis();

    if (cloudSyncAllowed())
    {
      sendHeartbeat();
    }
  }

  // Diagnostics
  static unsigned long
    lastDiag = 0;

  if (
    millis() - lastDiag
    >= DIAG_INTERVAL_MS
  )
  {
    lastDiag =
      millis();

    Serial.print(
      "DIAG | AP="
    );

    Serial.print(
      WiFi.softAPgetStationNum()
    );

    Serial.print(
      " | STA="
    );

    if (
      WiFi.status() ==
      WL_CONNECTED
    )
    {
      Serial.print(
        "ONLINE"
      );

      Serial.print(
        " | IP="
      );

      Serial.print(
        WiFi.localIP()
      );

      Serial.print(
        " | RSSI="
      );

      Serial.print(
        WiFi.RSSI()
      );
    }
    else
    {
      Serial.print(
        "OFFLINE"
      );
    }

    Serial.print(
      " | STATE="
    );

    Serial.print(
      stateName()
    );

    Serial.print(
      " | STORY="
    );

    Serial.print(
      storyName(
        currentStory
      )
    );

    Serial.print(
      " | QUEUE="
    );

    Serial.print(
      queueFileSize()
    );

    Serial.println(
      " bytes"
    );

    printCloudStatus();
  }
}

結語:真正的 Edge AIoT 教材,是把失敗也留下來

這次從 AP、STA、JQ6500、Web 狀態、Queue,到最後找出 DNS 問題的過程,很適合作為物聯網與智慧生活課程案例。學生真正需要學的不只是「把程式燒進 ESP32」,而是如何從 Serial Log 與系統狀態逐層定位問題:硬體有沒有動?本地服務有沒有動?Wi‑Fi 有沒有連?DNS 能不能解析?HTTPS 能不能建立?API 有沒有接受?資料最後有沒有出現在儀表板?

當這條診斷鏈建立起來,「水井三寶」就不只是一件會說故事的地方工藝作品,而成為一套把地方文化、嵌入式系統、Edge Web、IoT 與雲端資料服務串在一起的智慧互動展覽實作。

沒有留言:

張貼留言