Skip to content

第15周:个人作业 - 智幻走马灯Web控制界面设计与实现

和 AI 讨论需求

这周作业复杂度较高,所以从项目的一开始,我便尝试使用 AI 工具来确定项目方案。我日常使用的的是 Claude

首先,我向 Claude 3.7 提供了尽可能详细的背景资料,并描述了初步的需求。

我给 Claude 提供了最终项目的介绍,week10,week11 的个人作业的 md 文档,以及我写的第 15 周的课程讲义的文本,以便让 Claude 了解项目所需的背景资料。然后通过提示词进一步提出了要求:

“我给你提供了我的 Fab Academy 第15周的课程讲义,里面你可以看到有关个人作业的要求。 然后我还给你提供了我的 Final Project 的目前版本的文档,还有第10周和第11周的个人作业文档。我希望能在第10周和第11周作业的基础上,增加有关软件控制的功能,并能作为我的 Final Project 的一部分。 请先充分理解这些背景资料,然后使用中文与独立的 markdown 文档给出系统目标,系统架构(硬件架构和软件架构分开说明,使用文本绘图语言),技术选型与实现方案。编程 部分先不要展开,我们先就项目的关键问题进行讨论”

Claude 随后给出了非常长的详细的方案说明,并给了 A、B、C 3 个不同复杂程度的方案,我意识到 AI 的完美主义倾向会把简单需求弄成一个需要数周时间的复杂项目。我保存了其中的一个方案文档,新开了一个聊天并向 Claude 提出简化项目的要求。

我的简化需求的提示词如下:

第15周是我作业的需求,第10和第11周的文档是参考,请先充分理解第15周的文档,目前第15周的规划我觉得有点复杂,我们只有1天时间。请尽量保持简洁,达成要求即可,避免随意扩展功能。请帮我修改第15周的需文档,取消4个阶段的规划,直接补全完整项目所需文档程序。我希望能按你的文档完成整个项目的部署和测试。

下面的文档主体包括代码部分基本都是 Claude 一次性输出的,我在实际操作的过程,做了小范围的修改,比如添加插图,修改或补全说明等。


项目介绍

本周个人项目的主要目标是基于已有的硬件设计,构建一个直观、功能完善的软件界面,实现对智幻走马灯的远程控制与监控。该系统将整合第10周的手势控制系统,通过 Web 应用的形式提供更加丰富的交互体验,并为最终项目奠定软件基础。

项目核心目标:

  1. 设计并实现基于 Web 的用户界面,用于控制智幻走马灯
  2. 整合已有的手势控制功能
  3. 提供 LED 状态可视化与反馈
  4. 实现系统状态监控

技术选择与理由

核心技术选择

技术层面选择方案选择理由
服务器端ESP32C3 + Arduino + ESPAsyncWebServer1. 与现有硬件完全兼容 2. 资源占用小,适合嵌入式设备 3. 已在第11周验证过 ESP32C3 的网络能力
前端框架HTML + CSS + 少量 JavaScript1. 轻量级,适合 ESP32C3 存储容量有限的情况 2. 简单直观,开发效率高 3. 跨平台兼容性好
通信协议RESTful API1. 简单易用,适合基本控制功能 2. 符合 Web 标准,便于扩展 3. 开发与调试简便
数据存储SPIFFS1. ESP32C3 内置,无需额外硬件 2. 支持存储网页文件 3. Arduino 生态系统支持良好

技术选择详细说明

服务器端技术

选择在 ESP32C3 上直接实现 Web 服务器,而不是使用外部服务器或云平台的原因:

  1. 独立性:系统可以在无互联网环境下工作,只需本地 Wi-Fi 网络。
  2. 低延迟:控制指令直接在本地网络传输,无需经过互联网。
  3. 成本效益:无需额外硬件设备或云服务订阅。
  4. 与现有系统整合:可以直接扩展第11周已实现的网络功能。

ESP32C3 虽然资源有限(SRAM 仅 320KB),但通过精心优化的 Web 界面和资源压缩,完全可以实现所需功能。ESPAsyncWebServer 库支持异步请求处理,能够在处理 Web 请求的同时保持对手势传感器的响应。

前端技术

选择基于 HTML + CSS + 少量 JavaScript 开发前端界面的理由:

  1. 轻量级:最小化资源占用,适合 ESP32C3 的存储容量
  2. 开发效率:简单直观的技术栈,便于快速实现和调试
  3. 跨平台兼容:同一套界面可在手机、平板和电脑上使用
  4. 易于维护:简单的代码结构,便于理解和修改

避免使用 React 或 Vue 等复杂框架,因为这些框架虽然功能强大,但会占用较多存储空间,且对于简单控制界面来说过于复杂。

系统架构设计

整体架构

系统采用典型的客户端-服务器架构,分为三个主要层次:

这张草图是我要求 Claude 用 Mermaid 图表语言绘制

软件模块详细设计

服务器端模块(ESP32C3)

模块功能描述技术实现
Web 服务器提供静态文件服务 处理 RESTful API 请求ESPAsyncWebServer SPIFFS 文件系统
控制逻辑层处理 LED 控制指令 响应手势识别事件 管理系统状态Arduino C++ 状态管理逻辑
数据管理存储 LED 状态 记录系统状态内存变量 可选的 SPIFFS 持久化
硬件抽象层LED 控制 手势传感器接口直接 GPIO 控制 I2C 通信

前端模块(浏览器)

模块功能描述技术实现
控制面板LED 亮灯数量控制 LED 全开/全关控制HTML 表单元素 JavaScript 事件处理
状态显示LED 状态可视化 设备连接状态CSS 样式 JavaScript DOM 更新
通信服务RESTful API 调用 状态轮询Fetch API JavaScript 定时器

接口设计

RESTful API 设计

LED 控制接口

接口路径方法功能描述请求参数响应数据
/api/led/statusGET获取 LED 状态亮灯数量(0-6)
/api/led/controlPOST控制LEDcount: 亮灯数量 action: "all_on"/"all_off"成功/失败状态

示例请求和响应:

GET /api/led/status

json
// 响应
{
  "count": 3
}

POST /api/led/control

plain
// 请求 - 设置亮灯数量
count=4

// 或

// 请求 - 全部点亮
action=all_on

// 响应
{
  "success": true,
  "message": "LED数量已更新"
}

系统状态接口

接口路径方法功能描述请求参数响应数据
/api/system/infoGET获取系统信息系统名称、版本、IP地址、连接状态

示例响应:

GET /api/system/info

json
{
  "name": "SmartLantern",
  "version": "1.0.0",
  "ip": "192.168.1.100",
  "connected": true
}

用户界面设计

主界面布局

基于简化的界面设计,专注于核心功能和直观操作:

  1. 设备状态区:
    • 显示当前设备连接状态(通过颜色编码:绿色表示已连接,红色表示未连接)。
    • 显示设备IP地址。
    • 位于界面顶部,便于快速查看。
  2. LED控制区:
    • 6个独立LED状态指示器(对应6个物理LED)。
    • 使用颜色区分开启/关闭状态(绿色表示开启,灰色表示关闭)。
    • "增加"和"减少"按钮直接控制亮灯数量。
    • "全开"和"全关"按钮提供快捷操作。
    • 符合第10周手势控制的操作逻辑(上滑增加,下滑减少)。

控制面板功能

直观简洁的控制功能设计:

  1. LED 控制:
    • 直观的 LED 指示器显示当前亮灯状态。
    • "增加"和"减少"按钮控制亮灯数量。
    • "全开"和"全关"按钮提供一键操作。
    • 每次操作都有视觉反馈。
  2. 状态反馈:
    • 连接状态指示器提供网络连接反馈。
    • IP 地址显示便于多设备环境下识别。
    • 定期自动刷新状态,保持界面同步。

界面线框图

这张草图是我要求 Claude 用 Mermaid 图表语言绘制

实现代码

主项目文件夹

plain
/smart_lantern_control/
├── smart_lantern_control.ino     # Arduino主程序文件
├── data/                         # SPIFFS数据目录(网页文件)
│   └── index.html                # Web界面HTML文件
└── libraries/                    # 第三方库(Arduino IDE自动管理)

主程序代码 (smart_lantern_control.ino)

主程序负责初始化硬件、建立 Wi-Fi 连接、设置 Web 服务器,以及处理手势控制和LED状态更新。关键功能包括:

  • 初始化 SPIFFS 文件系统和手势传感器
  • 设置 Web 服务器路由,包括静态文件服务和 API 接口
  • 实现手势识别逻辑和 LED 控制
  • 添加文件管理功能,解决 ESP32FS 工具不可用的问题

添加的文件管理功能在访问/files路径时提供界面,允许上传和管理 Web 文件,解决了 ESP32FS 工具不可用的问题。这是一个重要的解决方案,确保即使没有专门的工具也能上传 Web 界面文件。

cpp
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <SPIFFS.h>
#include <Wire.h>
#include <ArduinoJson.h>
#include "XLOT_APDS9960AD.h"

// WiFi设置
const char* ssid = "xiao";
const char* password = "12345678";

// 服务器和WebSocket
AsyncWebServer server(80);

// 文件上传
File fsUploadFile;

// 引脚重定义 - 适应自制扩展板
#define SDA_PIN 20      // RX/D7 (GPIO20)
#define SCL_PIN 21      // TX/D6 (GPIO21)

// LED引脚定义
const int LED_PINS[] = {D5, D4, D3, D2, D1, D0};
const int LED_COUNT = 6;

// 手势传感器
XLOT_APDS9960AD apds;

// 控制变量
int ledCount = 0;              // 点亮的LED数量(0-6)
unsigned long lastGestureTime = 0;  // 最后手势时间戳
const int gestureDelay = 500;  // 手势识别间隔(毫秒)

// 系统信息
String deviceName = "SmartLantern";
String firmwareVersion = "1.0.0";

// 初始化SPIFFS
void initSPIFFS() {
  if (!SPIFFS.begin(true)) {
    Serial.println("SPIFFS挂载失败");
    return;
  }
  Serial.println("SPIFFS挂载成功");
  
  // 列出SPIFFS中的文件
  File root = SPIFFS.open("/");
  File file = root.openNextFile();
  
  Serial.println("SPIFFS文件列表:");
  while(file) {
    Serial.print("- ");
    Serial.print(file.name());
    Serial.print(" (");
    Serial.print(file.size());
    Serial.println(" 字节)");
    file = root.openNextFile();
  }
}

// 连接WiFi
void connectToWiFi() {
  Serial.println("连接到WiFi...");
  WiFi.begin(ssid, password);
  
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  
  Serial.println("");
  Serial.println("WiFi连接成功!");
  Serial.print("IP地址: ");
  Serial.println(WiFi.localIP());
}

// 更新LED显示
void updateLEDs() {
  for(int i = 0; i < LED_COUNT; i++) {
    // 如果i小于ledCount,点亮LED,否则熄灭
    digitalWrite(LED_PINS[i], (i < ledCount) ? HIGH : LOW);
  }
}

// 处理手势
void processGesture(uint8_t gesture) {
  switch(gesture) {
    case APDS9960_RIGHT:
      // 右划 - 全部点亮LED
      if(ledCount < LED_COUNT) {
        ledCount = LED_COUNT;
        updateLEDs();
        Serial.println("全部LED点亮");
      }
      break;
      
    case APDS9960_LEFT:
      // 左划 - 全部关闭LED
      if(ledCount > 0) {
        ledCount = 0;
        updateLEDs();
        Serial.println("全部LED关闭");
      }
      break;
      
    case APDS9960_UP:
      // 上划 - 增加LED亮灯数量
      if(ledCount < LED_COUNT) {
        ledCount++;
        updateLEDs();
        Serial.print("LED亮灯数量: ");
        Serial.println(ledCount);
      }
      break;
      
    case APDS9960_DOWN:
      // 下划 - 减少LED亮灯数量
      if(ledCount > 0) {
        ledCount--;
        updateLEDs();
        Serial.print("LED亮灯数量: ");
        Serial.println(ledCount);
      }
      break;
  }
}

// 设置Web服务器路由
void setupServer() {
  // 静态文件处理
  server.serveStatic("/", SPIFFS, "/").setDefaultFile("index.html");
  
  // API路由: 获取LED状态
  server.on("/api/led/status", HTTP_GET, [](AsyncWebServerRequest *request) {
    DynamicJsonDocument doc(128);
    doc["count"] = ledCount;
    
    String response;
    serializeJson(doc, response);
    
    request->send(200, "application/json", response);
  });
  
  // API路由: 控制LED
  server.on("/api/led/control", HTTP_POST, [](AsyncWebServerRequest *request) {
    // 默认响应
    DynamicJsonDocument responseDoc(128);
    responseDoc["success"] = false;
    responseDoc["message"] = "无效请求";
    
    if (request->hasParam("count", true)) {
      int newCount = request->getParam("count", true)->value().toInt();
      if (newCount >= 0 && newCount <= LED_COUNT) {
        ledCount = newCount;
        updateLEDs();
        
        responseDoc["success"] = true;
        responseDoc["message"] = "LED数量已更新";
      } else {
        responseDoc["message"] = "LED数量超出范围(0-6)";
      }
    } else if (request->hasParam("action", true)) {
      String action = request->getParam("action", true)->value();
      
      if (action == "all_on") {
        ledCount = LED_COUNT;
        updateLEDs();
        responseDoc["success"] = true;
        responseDoc["message"] = "所有LED已点亮";
      } else if (action == "all_off") {
        ledCount = 0;
        updateLEDs();
        responseDoc["success"] = true;
        responseDoc["message"] = "所有LED已关闭";
      } else {
        responseDoc["message"] = "未知动作";
      }
    }
    
    String response;
    serializeJson(responseDoc, response);
    
    request->send(200, "application/json", response);
  });
  
  // API路由: 获取系统信息
  server.on("/api/system/info", HTTP_GET, [](AsyncWebServerRequest *request) {
    DynamicJsonDocument doc(256);
    doc["name"] = deviceName;
    doc["version"] = firmwareVersion;
    doc["ip"] = WiFi.localIP().toString();
    doc["connected"] = WiFi.status() == WL_CONNECTED;
    
    String response;
    serializeJson(doc, response);
    
    request->send(200, "application/json", response);
  });
  
  // 文件上传相关路由
  // 文件管理界面 - 修复UTF-8编码问题
  server.on("/files", HTTP_GET, [](AsyncWebServerRequest *request) {
    String content = "<html><head>";
    content += "<title>智幻走马灯 - 文件管理</title>";
    content += "<meta charset='UTF-8'>"; // 添加UTF-8字符集声明
    content += "<meta name='viewport' content='width=device-width, initial-scale=1'>";
    content += "<style>";
    content += "body { font-family: Arial, sans-serif; margin: 20px; }";
    content += "h1 { color: #2196F3; }";
    content += ".file-list { margin: 20px 0; }";
    content += ".file-item { padding: 8px; border-bottom: 1px solid #eee; }";
    content += ".btn { padding: 8px 15px; background-color: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; margin-right: 5px; text-decoration: none; display: inline-block; }";
    content += ".btn-danger { background-color: #F44336; }";
    content += ".upload-form { margin: 20px 0; padding: 15px; background-color: #f5f5f5; border-radius: 5px; }";
    content += "</style>";
    content += "</head><body>";
    content += "<h1>智幻走马灯 - 文件管理</h1>";
    content += "<div class='upload-form'>";
    content += "<h2>上传文件</h2>";
    content += "<form method='POST' action='/upload' enctype='multipart/form-data'>";
    content += "<input type='file' name='upload'>";
    content += "<button class='btn' type='submit'>上传</button>";
    content += "</form>";
    content += "</div>";
    content += "<h2>文件列表</h2>";
    content += "<div class='file-list'>";
    
    File root = SPIFFS.open("/");
    File file = root.openNextFile();
    while(file) {
      String fileName = String(file.name());
      content += "<div class='file-item'>";
      content += fileName + " (" + String(file.size()) + " bytes) ";
      content += "<a class='btn' href='" + fileName + "'>查看</a> ";
      content += "<a class='btn btn-danger' href='/delete?file=" + fileName + "'>删除</a>";
      content += "</div>";
      file = root.openNextFile();
    }
    
    content += "</div>";
    content += "<p><a class='btn' href='/'>返回控制面板</a></p>";
    content += "</body></html>";
    request->send(200, "text/html; charset=utf-8", content); // 指定字符集为UTF-8
  });
  
  // 文件上传处理
  server.on("/upload", HTTP_POST, [](AsyncWebServerRequest *request) {
    request->redirect("/files");
  }, [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
    if (!index) {
      Serial.printf("接收上传: %s\n", filename.c_str());
      // 打开文件写入
      if (!filename.startsWith("/")) {
        filename = "/" + filename;
      }
      fsUploadFile = SPIFFS.open(filename, "w");
    }
    
    if (fsUploadFile) {
      // 写入文件
      fsUploadFile.write(data, len);
    }
    
    if (final) {
      if (fsUploadFile) {
        // 关闭文件
        fsUploadFile.close();
      }
      Serial.printf("上传完成: %s, %u bytes\n", filename.c_str(), index + len);
    }
  });
  
  // 文件删除
  server.on("/delete", HTTP_GET, [](AsyncWebServerRequest *request) {
    if (request->hasParam("file")) {
      String fileName = request->getParam("file")->value();
      if (SPIFFS.remove(fileName)) {
        request->redirect("/files?msg=删除成功");
      } else {
        request->send(500, "text/plain", "删除失败");
      }
    } else {
      request->send(400, "text/plain", "缺少文件参数");
    }
  });
  
  // 404处理
  server.onNotFound([](AsyncWebServerRequest *request) {
    request->send(404, "text/plain", "页面未找到");
  });
  
  // 启动服务器
  server.begin();
  Serial.println("HTTP服务器已启动");
}

void setup() {
  Serial.begin(115200);
  delay(3000);  // 增加延迟,确保有足够时间上传新代码
  Serial.println("\n智幻走马灯Web控制系统启动...");
  
  // 使用重定义的引脚初始化I2C
  Wire.begin(SDA_PIN, SCL_PIN);
  
  // 初始化所有LED引脚
  for(int i = 0; i < LED_COUNT; i++) {
    pinMode(LED_PINS[i], OUTPUT);
    digitalWrite(LED_PINS[i], LOW);  // 初始状态全部关闭
  }
  
  // 初始化手势传感器
  if(!apds.begin()){
    Serial.println("手势传感器初始化失败! 请检查接线。");
    // 错误指示 - 闪烁第一个LED
    while(1) {
      digitalWrite(LED_PINS[0], HIGH);
      delay(100);
      digitalWrite(LED_PINS[0], LOW);
      delay(100);
    }
  } else {
    Serial.println("手势传感器初始化成功!");
    apds.enableProximity(true);
    apds.enableGesture(true);
    apds.setProxGain(APDS9960_PGAIN_8X);
    apds.setGestureGain(APDS9960_PGAIN_8X);
    apds.setGestureGain(APDS9960_AGAIN_64X);
    apds.setGestureGain(APDS9960_GGAIN_8);
    
    // 成功指示 - 所有LED依次亮起再熄灭
    for(int i = 0; i < LED_COUNT; i++) {
      digitalWrite(LED_PINS[i], HIGH);
      delay(200);
    }
    delay(500);
    for(int i = 0; i < LED_COUNT; i++) {
      digitalWrite(LED_PINS[i], LOW);
      delay(200);
    }
  }
  
  // 初始化SPIFFS
  initSPIFFS();
  
  // 连接WiFi
  connectToWiFi();
  
  // 设置服务器
  setupServer();
  
  Serial.println("系统初始化完成!");
}

void loop() {
  // 读取手势
  uint8_t gesture = apds.readGesture();
  
  // 处理手势(添加延迟以防止过快响应)
  if(gesture != 0 && millis() - lastGestureTime > gestureDelay) {
    lastGestureTime = millis();
    processGesture(gesture);
  }
  
  // 检查WiFi连接
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("WiFi连接断开,尝试重连...");
    WiFi.reconnect();
  }
  
  // 添加短延迟,减少CPU使用
  delay(10);
}

HTML 界面代码 (index.html)

HTML 文件实现了响应式设计的 Web 界面:

  • 状态指示区显示设备连接状态和 IP
  • LED 控制区提供视觉反馈和控制按钮
  • JavaScript 部分处理 API 调用和状态更新
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>智幻走马灯控制</title>
  <style>
    body {
      font-family: Arial, sans-serif;
      max-width: 600px;
      margin: 0 auto;
      padding: 20px;
      background-color: #f5f5f5;
    }
    
    .header {
      text-align: center;
      margin-bottom: 20px;
    }
    
    .status-bar {
      background-color: #fff;
      padding: 10px;
      border-radius: 5px;
      margin-bottom: 20px;
      display: flex;
      justify-content: space-between;
      align-items: center;
    }
    
    .status-indicator {
      display: flex;
      align-items: center;
    }
    
    .status-dot {
      width: 12px;
      height: 12px;
      border-radius: 50%;
      margin-right: 8px;
    }
    
    .connected {
      background-color: #4CAF50;
    }
    
    .disconnected {
      background-color: #F44336;
    }
    
    .control-card {
      background-color: #fff;
      padding: 20px;
      border-radius: 5px;
      margin-bottom: 20px;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    }
    
    .led-container {
      display: flex;
      justify-content: space-between;
      margin-bottom: 20px;
    }
    
    .led {
      width: 40px;
      height: 40px;
      border-radius: 50%;
      background-color: #ddd;
      margin: 0 5px;
    }
    
    .led.active {
      background-color: #4CAF50;
      box-shadow: 0 0 10px #4CAF50;
    }
    
    .control-buttons {
      display: flex;
      justify-content: space-between;
      margin-bottom: 20px;
    }
    
    .button-group {
      display: flex;
    }
    
    button {
      padding: 10px 15px;
      margin: 0 5px;
      border: none;
      border-radius: 4px;
      background-color: #2196F3;
      color: white;
      cursor: pointer;
      font-size: 14px;
    }
    
    button:hover {
      background-color: #0b7dda;
    }
    
    .footer {
      text-align: center;
      font-size: 12px;
      color: #666;
      margin-top: 30px;
    }
  </style>
</head>
<body>
  <div class="header">
    <h1>智幻走马灯控制</h1>
  </div>
  
  <div class="status-bar">
    <div class="status-indicator">
      <div id="status-dot" class="status-dot disconnected"></div>
      <span id="status-text">未连接</span>
    </div>
    <div id="ip-address">IP: -</div>
  </div>
  
  <div class="control-card">
    <h2>LED控制</h2>
    <div class="led-container" id="led-display">
      <div class="led" id="led-0"></div>
      <div class="led" id="led-1"></div>
      <div class="led" id="led-2"></div>
      <div class="led" id="led-3"></div>
      <div class="led" id="led-4"></div>
      <div class="led" id="led-5"></div>
    </div>
    
    <div class="control-buttons">
      <div class="button-group">
        <button id="btn-decrease">减少 ⬇️</button>
        <button id="btn-increase">增加 ⬆️</button>
      </div>
      <div class="button-group">
        <button id="btn-all-off">全部关闭 ◀️</button>
        <button id="btn-all-on">全部点亮 ▶️</button>
      </div>
    </div>
  </div>
  
  <div class="footer">
    <p>智幻走马灯控制系统 v1.0.0</p>
    <p>基于ESP32C3 + Web技术</p>
  </div>
  <script>
    // 状态变量
    let ledCount = 0;
    let isConnected = false;
    
    // DOM元素
    const statusDot = document.getElementById('status-dot');
    const statusText = document.getElementById('status-text');
    const ipAddress = document.getElementById('ip-address');
    const ledElements = Array.from(document.querySelectorAll('.led'));
    
    // 按钮元素
    const btnIncrease = document.getElementById('btn-increase');
    const btnDecrease = document.getElementById('btn-decrease');
    const btnAllOn = document.getElementById('btn-all-on');
    const btnAllOff = document.getElementById('btn-all-off');
    
    // 更新LED显示
    function updateLedDisplay() {
      ledElements.forEach((led, index) => {
        if (index < ledCount) {
          led.classList.add('active');
        } else {
          led.classList.remove('active');
        }
      });
    }
    
    // 更新连接状态
    function updateConnectionStatus(connected, ip) {
      isConnected = connected;
      
      if (connected) {
        statusDot.classList.remove('disconnected');
        statusDot.classList.add('connected');
        statusText.textContent = '已连接';
        ipAddress.textContent = `IP: ${ip}`;
      } else {
        statusDot.classList.remove('connected');
        statusDot.classList.add('disconnected');
        statusText.textContent = '未连接';
        ipAddress.textContent = 'IP: -';
      }
    }
    
    // 获取LED状态
    async function getLedStatus() {
      try {
        const response = await fetch('/api/led/status');
        const data = await response.json();
        
        ledCount = data.count;
        updateLedDisplay();
      } catch (error) {
        console.error('获取LED状态失败:', error);
      }
    }
    
    // 控制LED
    async function controlLed(params) {
      try {
        const formData = new URLSearchParams(params);
        const response = await fetch('/api/led/control', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
          },
          body: formData
        });
        
        const data = await response.json();
        
        if (data.success) {
          await getLedStatus();
        } else {
          console.error('控制LED失败:', data.message);
        }
      } catch (error) {
        console.error('发送控制请求失败:', error);
      }
    }
    
    // 获取系统信息
    async function getSystemInfo() {
      try {
        const response = await fetch('/api/system/info');
        const data = await response.json();
        
        updateConnectionStatus(data.connected, data.ip);
      } catch (error) {
        console.error('获取系统信息失败:', error);
        updateConnectionStatus(false, '-');
      }
    }
    
    // 初始化
    async function initialize() {
      await getSystemInfo();
      await getLedStatus();
      
      // 设置定时刷新
      setInterval(getSystemInfo, 5000);
      setInterval(getLedStatus, 2000);
    }
    
    // 事件监听器
    btnIncrease.addEventListener('click', () => {
      if (ledCount < 6) {
        controlLed({ count: ledCount + 1 });
      }
    });
    
    btnDecrease.addEventListener('click', () => {
      if (ledCount > 0) {
        controlLed({ count: ledCount - 1 });
      }
    });
    
    btnAllOn.addEventListener('click', () => {
      controlLed({ action: 'all_on' });
    });
    
    btnAllOff.addEventListener('click', () => {
      controlLed({ action: 'all_off' });
    });
    
    // 启动应用
    document.addEventListener('DOMContentLoaded', initialize);
  </script>
</body>
</html>

上面的 HTML 文件,我在 Windsurf IDE 中进行预览,可以看到页面在浏览器中显示的实际的效果。

index.html 文件在 Windsurf IDE 中进行预览的效果,感觉还不错

项目实施步骤

准备工作

首先需要准备以下材料和工具:

  1. 硬件设备:
    • XIAO ESP32C3 开发板(自制)
    • APDS-9960手势传感器
    • 连接线/杜邦线
    • USB数据线
  2. 软件工具:

安装必要的库

在 Arduino IDE 中安装必要的库:

  1. Arduino 内置库管理器:
    • 打开 Arduino IDE
    • 选择"工具(Tools)" -> "管理库(Manage Libraries)"
    • 搜索并安装 ArduinoJson 库
  2. 手动安装第三方库:
    • ESPAsyncWebServer 和 AsyncTCP 库需要手动安装
    • 从 GitHub 下载库文件 (ZIP格式)
    • 在 Arduino IDE中,选择"项目" -> "加载库" -> "添加.ZIP库"
    • 选择下载的 ZIP 文件
  3. 确认已安装 XLOT_APDS9960AD 库(第10周使用的库)

硬件连接

按照第10周作业的方式连接手势传感器:

APDS-9960引脚连接到扩展板引脚XIAO ESP32C3 引脚功能
VCC(红线)J1-2 (3.3V)3.3V电源正极
GND(黑线)J1-1 (GND)GND电源地线
SDA(黄线)J1-3 (RX/D7)GPIO20I2C数据线(软件实现)
SCL(绿线)J1-4 (TX/D6)GPIO21I2C时钟线(软件实现)

使用Y型分线器实现两个设备共享电源和地线的连接示意图

安装步骤

  1. 准备并安装必要的库(ESPAsyncWebServer、AsyncTCP、ArduinoJson)
  2. 创建项目目录结构,包括主程序和 data 文件夹
  3. 编译并上传主程序到 XIAO ESP32C3,打开串口监视器,可以看到一个提示的 IP 地址。

上传成功后,串口监视器会给出状态信息并提示一个 IP 地址

  1. 用电脑通过Web浏览器访问http://[ESP32IP地址]/files上传index.html,注意电脑需要和 XIAO ESP32C3 连接相同的 Wi-Fi,才能成功访问。

XIAO ESP32C3 展示的文件管理界面,我们可以通过这个界面上传 index.html 文件

  1. 现在可以通过电脑或手机的浏览器访问http://[ESP32IP地址]/,来测试 Web 控制界面。

使用鼠标点击全部点亮按钮

开发板上的 LED 全部被点亮了。

现在可以全面测试手势与 Web 端的控制效果了。

遇到的挑战与解决方案

ESP32FS 工具不可用

问题:ESP32FS 工具无法在 Arduino IDE 中找到,无法直接上传 HTML 文件。

解决方案:添加了 Web 文件管理功能,通过/files路径提供文件上传界面,使用户可以直接通过 Web 浏览器上传 HTML 文件,避免了对 ESP32FS 工具的依赖。

页面编码问题

问题:初次访问文件管理界面时出现中文乱码。

解决方案:虽然有乱码,但功能正常,通过文件管理界面成功上传了编码正确的 index.html 文件,解决了问题。然后告知 Claude 乱码问题,Claude 提供了修改后的 Arudino 程序后问题被解决。

跨设备访问

问题:电脑无法访问 ESP32C3 的 Web 界面。

解决方案:确认需要将所有设备连接到 ESP32C3 创建的 Wi-Fi 网络才能访问 Web 界面。

测试结果

系统测试结果表明项目功能完整:

  • Wi-Fi 连接成功建立,IP地址正确显示
  • 文件上传功能正常工作,成功上传index.html
  • Web界面正确加载,显示LED状态和连接信息
  • 按钮控制功能正常,可以增减LED亮灯数量和全开/全关
  • 手势控制功能正常,与Web控制同步

结论与后续开发

本项目成功实现了智幻走马灯的Web控制界面,整合了手势控制功能并提供了直观的用户界面。系统采用轻量级设计,适合ESP32C3的资源限制,同时保持了良好的用户体验。

通过添加Web文件管理功能,解决了ESP32FS工具不可用的问题,提供了更灵活的部署方式。项目为"智幻走马灯"最终系统奠定了软件基础,证明了ESP32C3能够同时处理传感器输入和Web服务的能力。

后续可考虑添加LED亮度控制、电机控制功能、多设备同步等拓展,进一步提升系统功能和用户体验。