// FursuitTie - Wokwi 单文件版本
// 自动生成时间: 2026-02-22 01:04:05
//
// 此文件由多个源文件合并而成,用于 Wokwi 在线模拟器
// 原始项目: https://github.com/yourusername/FursuitTie
//
// 使用方法:
// 1. 访问 https://wokwi.com/
// 2. 创建新的 ESP32 项目
// 3. 将此文件内容复制到 sketch.ino
// 4. 上传 diagram.json 和 libraries.txt
// 5. 点击 "Start Simulation"
// 系统库
#include <Adafruit_NeoPixel.h>
#include <Arduino.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include <WebServer.h>
#include <WiFi.h>
================================================================================
// config.h
================================================================================
// 引脚定义
#define DATA_PIN 2
#define BUTTON_MODE_PIN 4 // 模式切换按键
#define BUTTON_TEXT_PIN 5 // 文字切换按键
#define BUTTON_WIFI_PIN 6 // WiFi 热点切换按键(长按 2 秒)
#define FAN_PIN 15 // 风扇 PWM 控制引脚
// LED 矩阵配置
#define WIDTH 32
#define HEIGHT 8
#define NUM_LEDS (WIDTH * HEIGHT)
// PWM 配置
#define PWM_FREQ 25000 // PWM 频率 25kHz
#define PWM_RESOLUTION 8 // 8 位分辨率 (0-255)
// WiFi AP 配置
#define WIFI_SSID "LED_Tie_AP"
#define WIFI_PASSWORD "12345678"
#define WIFI_LONG_PRESS_MS 2000 // 长按 2 秒触发 WiFi 切换
// 文字存储配置
#define MAX_TEXT_SLOTS 5 // 最多支持 5 句话
#define MAX_CUSTOM_CHARS 10 // 每句最多 10 个字符
#define CUSTOM_CHAR_WIDTH 8 // 每个字符 8x8 点阵
// 按键防抖配置
constexpr uint16_t DEBOUNCE_MS = 200; // 按键防抖时间
// 模式定义
enum Mode : uint8_t {
MODE_RAINBOW = 0, // 彩虹流动
MODE_BREATHE, // 呼吸灯
MODE_CHASE, // 跑马灯
MODE_WAVE, // 波浪模式
MODE_SCANNER, // 扫描模式
MODE_GRADIENT, // 渐变模式
MODE_COUNT // 模式总数
};
================================================================================
// hardware.h
================================================================================
// LED 工具函数
void initLED();
uint16_t XY(uint8_t x, uint8_t y);
uint32_t Wheel(uint8_t pos);
void setupBaseHueLUT();
uint8_t* getBaseHueLUT();
// WiFi/HTTP 功能
void setupWiFiAP();
void stopWiFiAP();
void handleAPICommand();
void handleAPIStatus();
// 按键处理
void checkModeButton();
void checkTextButton();
void checkWiFiButton();
// 风扇控制
void setFanSpeed(uint8_t speed);
================================================================================
// hardware.cpp
================================================================================
// 预计算:每一列的基础色相(0~255)
static uint8_t baseHueByX[WIDTH];
// LED 初始化
void initLED() {
strip.begin();
strip.show();
}
// serpentine(蛇形)映射:偶数行正向,奇数行反向
uint16_t XY(uint8_t x, uint8_t y) {
if (x >= WIDTH || y >= HEIGHT) return 0; // 边界保护
if ((y & 0x01) == 0) {
return static_cast<uint16_t>(y) * WIDTH + x;
} else {
return static_cast<uint16_t>(y) * WIDTH + (WIDTH - 1 - x);
}
}
// 0~255 色轮
uint32_t Wheel(uint8_t pos) {
pos = 255 - pos;
if (pos < 85) {
return strip.Color(255 - pos * 3, 0, pos * 3);
}
if (pos < 170) {
pos -= 85;
return strip.Color(0, pos * 3, 255 - pos * 3);
}
pos -= 170;
return strip.Color(pos * 3, 255 - pos * 3, 0);
}
void setupBaseHueLUT() {
for (uint8_t x = 0; x < WIDTH; x++) {
// 横向均匀映射到 0~255
baseHueByX[x] = static_cast<uint8_t>((static_cast<uint16_t>(x) * 256) / WIDTH);
}
}
uint8_t* getBaseHueLUT() {
return baseHueByX;
}
// 设置风扇速度
void setFanSpeed(uint8_t speed) {
ledcWrite(FAN_PIN, speed);
}
// 初始化 WiFi 热点
void setupWiFiAP() {
Serial.println("正在启动 WiFi 热点...");
WiFi.mode(WIFI_AP);
WiFi.softAP(WIFI_SSID, WIFI_PASSWORD);
IPAddress IP = WiFi.softAPIP();
Serial.print("WiFi 热点已启动");
Serial.print(" | SSID: ");
Serial.print(WIFI_SSID);
Serial.print(" | 密码: ");
Serial.print(WIFI_PASSWORD);
Serial.print(" | IP: ");
Serial.println(IP);
// 配置 HTTP 路由
server.on("/api/command", HTTP_POST, handleAPICommand);
server.on("/api/status", HTTP_GET, handleAPIStatus);
// 启动 HTTP 服务器
server.begin();
Serial.println("HTTP API 服务器已启动,端口: 80");
wifiEnabled = true;
}
// 关闭 WiFi 热点
void stopWiFiAP() {
Serial.println("正在关闭 WiFi 热点...");
server.stop();
WiFi.softAPdisconnect(true);
WiFi.mode(WIFI_OFF);
wifiEnabled = false;
Serial.println("WiFi 热点已关闭");
}
// HTTP 处理:POST /api/command
void handleAPICommand() {
// 添加 CORS 头
server.sendHeader("Access-Control-Allow-Origin", "*");
server.sendHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
server.sendHeader("Access-Control-Allow-Headers", "Content-Type");
// 处理 OPTIONS 预检请求
if (server.method() == HTTP_OPTIONS) {
server.send(200);
return;
}
if (server.hasArg("plain")) {
String body = server.arg("plain");
Serial.print("HTTP 收到命令: ");
Serial.println(body);
// 需要在 control.cpp 中实现
processCommand(body);
// 返回成功响应
server.send(200, "application/json", "{\"status\":\"ok\"}");
} else {
server.send(400, "application/json", "{\"status\":\"error\",\"message\":\"缺少 JSON 数据\"}");
}
}
// HTTP 处理:GET /api/status
void handleAPIStatus() {
// 添加 CORS 头
server.sendHeader("Access-Control-Allow-Origin", "*");
server.sendHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
server.sendHeader("Access-Control-Allow-Headers", "Content-Type");
// 处理 OPTIONS 预检请求
if (server.method() == HTTP_OPTIONS) {
server.send(200);
return;
}
// 需要在 FursuitTie.ino 中声明的全局变量
JsonDocument doc;
doc["mode"] = currentMode;
doc["brightness"] = brightness;
doc["speed"] = frameDelay;
doc["fan"] = fanSpeed;
doc["text"] = currentTextIndex;
doc["freeHeap"] = ESP.getFreeHeap();
String response;
serializeJson(doc, response);
server.send(200, "application/json", response);
}
// 按键检测 - 模式切换
void checkModeButton() {
static uint8_t lastState = HIGH;
uint8_t currentState = digitalRead(BUTTON_MODE_PIN);
if (lastState == HIGH && currentState == LOW) {
uint32_t now = millis();
if (now - lastModeButtonPress > DEBOUNCE_MS) {
lastModeButtonPress = now;
currentMode = (currentMode + 1) % MODE_COUNT;
Serial.print("切换到模式: ");
Serial.println(currentMode);
}
}
lastState = currentState;
}
// 按键检测 - 文字切换
void checkTextButton() {
static uint8_t lastState = HIGH;
uint8_t currentState = digitalRead(BUTTON_TEXT_PIN);
if (lastState == HIGH && currentState == LOW) {
uint32_t now = millis();
if (now - lastTextButtonPress > DEBOUNCE_MS) {
lastTextButtonPress = now;
// 循环切换:0 → 1 → 2 → ... → 5 → 0
currentTextIndex++;
if (currentTextIndex > MAX_TEXT_SLOTS) {
currentTextIndex = 0;
}
// 跳过空句子
uint8_t attempts = 0;
while (currentTextIndex > 0 && textSlotLengths[currentTextIndex - 1] == 0 && attempts < MAX_TEXT_SLOTS) {
currentTextIndex++;
if (currentTextIndex > MAX_TEXT_SLOTS) {
currentTextIndex = 0;
break;
}
attempts++;
}
// 输出状态
if (currentTextIndex > 0) {
Serial.print("文字切换到句子");
Serial.println(currentTextIndex - 1);
} else {
Serial.println("文字显示: 关闭");
}
}
}
lastState = currentState;
}
// 按键检测 - WiFi 热点切换(长按 2 秒)
void checkWiFiButton() {
uint8_t currentState = digitalRead(BUTTON_WIFI_PIN);
if (currentState == LOW) {
// 按键按下
if (!wifiButtonPressed) {
wifiButtonPressed = true;
wifiButtonPressTime = millis();
} else {
// 检查是否长按超过 2 秒
if (millis() - wifiButtonPressTime >= WIFI_LONG_PRESS_MS) {
// 触发 WiFi 切换
if (wifiEnabled) {
stopWiFiAP();
} else {
setupWiFiAP();
}
// 等待按键释放,避免重复触发
while (digitalRead(BUTTON_WIFI_PIN) == LOW) {
delay(10);
}
wifiButtonPressed = false;
}
}
} else {
// 按键释放
wifiButtonPressed = false;
}
}
================================================================================
// display.h
================================================================================
// 动画模式
void modeRainbow(uint8_t t);
void modeBreathe(uint8_t t);
void modeChase(uint8_t t);
void modeWave(uint8_t t);
void modeScanner(uint8_t t);
void modeGradient(uint8_t t);
// 文字显示
void overlayText(uint16_t t);
// 文字存储
void saveAllTexts();
void loadAllTexts();
================================================================================
// display.cpp
================================================================================
// 模式 0:彩虹流动
void modeRainbow(uint8_t t) {
uint8_t* baseHueByX = getBaseHueLUT();
for (uint8_t y = 0; y < HEIGHT; y++) {
for (uint8_t x = 0; x < WIDTH; x++) {
uint8_t hue = static_cast<uint8_t>(baseHueByX[x] + t);
strip.setPixelColor(XY(x, y), Wheel(hue));
}
}
}
// 模式 1:呼吸灯(全屏同色,亮度正弦波动)
void modeBreathe(uint8_t t) {
// sin() 返回 -1~1,映射到 64~255 范围
float phase = (t * 2.0 * PI) / 255.0;
uint8_t brightness = 64 + (uint8_t)(95.5 * (1.0 + sin(phase)));
uint32_t color = strip.Color(brightness, 0, brightness); // 紫色呼吸
for (uint16_t i = 0; i < NUM_LEDS; i++) {
strip.setPixelColor(i, color);
}
}
// 模式 2:跑马灯(竖条从左到右扫过)
void modeChase(uint8_t t) {
strip.clear();
uint8_t pos = (t / 2) % WIDTH; // 每 2 帧移动一列
for (uint8_t y = 0; y < HEIGHT; y++) {
strip.setPixelColor(XY(pos, y), Wheel(t));
}
}
// 模式 3:波浪模式(正弦波流动)
void modeWave(uint8_t t) {
for (uint8_t x = 0; x < WIDTH; x++) {
// 计算正弦波:sin((x + t) * 频率)
float wave = sin((x + t * 0.5) * 0.3);
// 映射到 0-255 范围
uint8_t brightness = 128 + (uint8_t)(127 * wave);
// 使用彩虹色,随 x 变化
uint8_t hue = (x * 8 + t) & 0xFF;
uint32_t color = Wheel(hue);
// 调整亮度
uint8_t r = (color >> 16) & 0xFF;
uint8_t g = (color >> 8) & 0xFF;
uint8_t b = color & 0xFF;
r = (r * brightness) >> 8;
g = (g * brightness) >> 8;
b = (b * brightness) >> 8;
// 整列显示相同颜色
for (uint8_t y = 0; y < HEIGHT; y++) {
strip.setPixelColor(XY(x, y), strip.Color(r, g, b));
}
}
}
// 模式 4:扫描模式(霹雳游侠效果)
void modeScanner(uint8_t t) {
strip.clear();
// 计算扫描位置(来回扫描)
uint8_t pos = (t / 2) % (WIDTH * 2);
if (pos >= WIDTH) {
pos = WIDTH * 2 - 1 - pos; // 反向
}
// 主亮线(红色)
for (uint8_t y = 0; y < HEIGHT; y++) {
strip.setPixelColor(XY(pos, y), strip.Color(255, 0, 0));
}
// 拖尾效果
for (int8_t i = 1; i <= 3; i++) {
if (pos >= i) {
uint8_t brightness = 255 >> i; // 逐渐衰减
for (uint8_t y = 0; y < HEIGHT; y++) {
strip.setPixelColor(XY(pos - i, y), strip.Color(brightness, 0, 0));
}
}
if (pos + i < WIDTH) {
uint8_t brightness = 255 >> i;
for (uint8_t y = 0; y < HEIGHT; y++) {
strip.setPixelColor(XY(pos + i, y), strip.Color(brightness, 0, 0));
}
}
}
}
// 模式 5:渐变模式(平滑颜色过渡)
void modeGradient(uint8_t t) {
for (uint8_t x = 0; x < WIDTH; x++) {
for (uint8_t y = 0; y < HEIGHT; y++) {
// 水平渐变 + 时间变化
uint8_t hue = (x * 256 / WIDTH + t) & 0xFF;
strip.setPixelColor(XY(x, y), Wheel(hue));
}
}
}
// 文字叠加渲染(垂直翻转,叠加在当前画面上)
void overlayText(uint16_t t) {
if (currentTextIndex == 0) return;
uint8_t slotIndex = currentTextIndex - 1;
if (slotIndex >= MAX_TEXT_SLOTS || textSlotLengths[slotIndex] == 0) return;
// 使用 8x8 自定义字体
uint8_t charWidth = CUSTOM_CHAR_WIDTH;
uint8_t textLen = textSlotLengths[slotIndex];
// 计算总宽度(字符数 * (字符宽度+1),+1 是间距)
int16_t totalWidth = textLen * (charWidth + 1);
// 滚动偏移(从右向左)
int16_t offset = WIDTH - (t / 2) % (WIDTH + totalWidth);
for (uint8_t charIdx = 0; charIdx < textLen; charIdx++) {
const uint8_t* fontData = textSlots[slotIndex][charIdx];
int16_t charX = offset + charIdx * (charWidth + 1);
// 绘制字符(垂直翻转)
for (uint8_t col = 0; col < charWidth; col++) {
int16_t x = charX + col;
if (x >= 0 && x < WIDTH) {
uint8_t columnData = fontData[col];
// 垂直翻转:y 坐标反转
for (uint8_t y = 0; y < 8; y++) {
if (columnData & (1 << (7 - y))) {
// 白色文字(叠加),y 坐标翻转
strip.setPixelColor(XY(x, 7 - y), strip.Color(255, 255, 255));
}
}
}
}
}
}
// 保存所有文字到 NVS
void saveAllTexts() {
// 存储格式:5字节长度数组 + 400字节数据(5句×10字符×8列)
uint8_t buffer[405];
// 前5字节存储每句话的长度
memcpy(buffer, textSlotLengths, 5);
// 后400字节存储所有文字数据
memcpy(buffer + 5, textSlots, 400);
preferences.putBytes("texts", buffer, sizeof(buffer));
Serial.println("所有文字已保存到 NVS");
}
// 从 NVS 加载所有文字
void loadAllTexts() {
uint8_t buffer[405];
size_t len = preferences.getBytes("texts", buffer, sizeof(buffer));
if (len == sizeof(buffer)) {
// 恢复长度数组
memcpy(textSlotLengths, buffer, 5);
// 恢复文字数据
memcpy(textSlots, buffer + 5, 400);
Serial.println("已从 NVS 加载文字数据");
for (uint8_t i = 0; i < MAX_TEXT_SLOTS; i++) {
if (textSlotLengths[i] > 0) {
Serial.print(" 句子");
Serial.print(i);
Serial.print(": ");
Serial.print(textSlotLengths[i]);
Serial.println(" 个字符");
}
}
} else {
Serial.println("NVS 中无文字数据");
}
}
================================================================================
// control.h
================================================================================
// 命令处理
void processCommand(String json);
================================================================================
// control.cpp
================================================================================
// 全局变量声明(在 FursuitTie.ino 中定义)
// 处理 JSON 命令
void processCommand(String json) {
JsonDocument doc;
DeserializationError error = deserializeJson(doc, json);
if (error) {
Serial.print("JSON 解析失败: ");
Serial.println(error.c_str());
return;
}
const char* cmd = doc["cmd"];
Serial.print("收到命令: ");
Serial.println(cmd);
if (strcmp(cmd, "mode") == 0) {
// 切换模式: {"cmd":"mode","value":0}
uint8_t mode = doc["value"];
if (mode < MODE_COUNT) {
currentMode = mode;
Serial.print("模式切换到: ");
Serial.println(mode);
}
}
else if (strcmp(cmd, "text") == 0) {
// 切换文字显示: {"cmd":"text","value":"off"} 或 {"cmd":"text","value":"slot0"}
const char* text = doc["value"];
if (strcmp(text, "off") == 0 || strcmp(text, "none") == 0) {
currentTextIndex = 0;
Serial.println("文字: 关闭");
} else if (strncmp(text, "slot", 4) == 0) {
// 支持 slot0-slot4
uint8_t slotNum = atoi(text + 4);
if (slotNum < MAX_TEXT_SLOTS && textSlotLengths[slotNum] > 0) {
currentTextIndex = slotNum + 1;
Serial.print("文字: 切换到句子");
Serial.println(slotNum);
} else {
Serial.print("错误: 句子");
Serial.print(slotNum);
Serial.println(" 为空");
}
} else if (strcmp(text, "on") == 0) {
// 向后兼容:on 表示切换到第一句
if (textSlotLengths[0] > 0) {
currentTextIndex = 1;
Serial.println("文字: 开启(句子0)");
} else {
Serial.println("错误: 句子0 为空");
}
}
}
else if (strcmp(cmd, "custom_text") == 0) {
// 自定义点阵 8x8: {"cmd":"custom_text","data":[[255,129,129,129,129,129,129,255],[...]],,"slot":0}
JsonArray data = doc["data"];
uint8_t slotNum = doc["slot"] | 0; // 默认槽位0
if (slotNum >= MAX_TEXT_SLOTS) {
Serial.println("错误: 槽位超出范围");
return;
}
uint8_t textLen = min((int)data.size(), MAX_CUSTOM_CHARS);
textSlotLengths[slotNum] = textLen;
for (uint8_t i = 0; i < textLen; i++) {
JsonArray charData = data[i];
uint8_t cols = min((int)charData.size(), CUSTOM_CHAR_WIDTH);
for (uint8_t j = 0; j < cols; j++) {
textSlots[slotNum][i][j] = charData[j];
}
// 如果不足 8 列,填充 0
for (uint8_t j = cols; j < CUSTOM_CHAR_WIDTH; j++) {
textSlots[slotNum][i][j] = 0;
}
}
// 保存到 NVS
saveAllTexts();
// 自动显示
currentTextIndex = slotNum + 1;
Serial.print("自定义文字(8x8),字符数: ");
Serial.print(textLen);
Serial.print(",保存到句子");
Serial.println(slotNum);
}
else if (strcmp(cmd, "brightness") == 0) {
// 调整亮度: {"cmd":"brightness","value":128}
brightness = doc["value"];
strip.setBrightness(brightness);
Serial.print("亮度: ");
Serial.println(brightness);
}
else if (strcmp(cmd, "speed") == 0) {
// 调整速度: {"cmd":"speed","value":50}
frameDelay = doc["value"];
Serial.print("帧延时: ");
Serial.println(frameDelay);
}
else if (strcmp(cmd, "fan") == 0) {
// 调整风扇速度: {"cmd":"fan","value":128}
fanSpeed = doc["value"];
setFanSpeed(fanSpeed);
Serial.print("风扇速度: ");
Serial.println(fanSpeed);
}
}
================================================================================
// FursuitTie.ino
================================================================================
// 全局变量定义
uint8_t brightness = 255; // 可调亮度
uint8_t frameDelay = 30; // 可调帧延时
uint8_t fanSpeed = 0; // 风扇速度 (0-255)
Adafruit_NeoPixel strip(NUM_LEDS, DATA_PIN, NEO_GRB + NEO_KHZ800);
// WiFi 和 HTTP 服务器
WebServer server(80);
bool wifiEnabled = false;
uint32_t wifiButtonPressTime = 0;
bool wifiButtonPressed = false;
// 文字预设存储
Preferences preferences;
// 模式和按键状态
uint8_t currentMode = MODE_RAINBOW;
uint32_t lastModeButtonPress = 0;
uint32_t lastTextButtonPress = 0;
uint8_t currentTextIndex = 0; // 0=关闭, 1-5=第1-5句话
// 多句话存储(最多 5 句,每句最多 10 个字符,每字符 8x8 点阵)
uint8_t textSlots[MAX_TEXT_SLOTS][MAX_CUSTOM_CHARS][CUSTOM_CHAR_WIDTH];
uint8_t textSlotLengths[MAX_TEXT_SLOTS] = {0}; // 每句话的字符数
void setup() {
Serial.begin(115200);
pinMode(BUTTON_MODE_PIN, INPUT_PULLUP); // 模式按键
pinMode(BUTTON_TEXT_PIN, INPUT_PULLUP); // 文字按键
pinMode(BUTTON_WIFI_PIN, INPUT_PULLUP); // WiFi 按键
// 初始化 PWM 风扇控制
ledcAttach(FAN_PIN, PWM_FREQ, PWM_RESOLUTION);
setFanSpeed(fanSpeed); // 初始化为 0(停止)
initLED();
strip.setBrightness(brightness);
strip.show();
setupBaseHueLUT();
// 初始化 Preferences(NVS 存储)
preferences.begin("led-tie", false); // 命名空间: led-tie, 读写模式
Serial.println("NVS 存储已初始化");
// 从 NVS 加载所有文字
loadAllTexts();
// 如果有文字数据,自动显示第一句
if (textSlotLengths[0] > 0) {
currentTextIndex = 1;
Serial.println("已自动显示第一句话");
}
Serial.println("LED 领带启动");
Serial.println("蓝色按键: 切换动画模式 | 绿色按键: 循环切换文字(句子1→句子2→...→关闭) | GPIO6 按键: 长按 2 秒切换 WiFi 热点");
Serial.println("---");
Serial.println("串口命令模式已启用(用于 Wokwi 调试)");
Serial.println("发送 JSON 命令测试,例如:");
Serial.println("{\"cmd\":\"mode\",\"value\":1}");
Serial.println("{\"cmd\":\"custom_text\",\"data\":[[60,66,165,129,165,153,66,60]],\"slot\":0}");
Serial.println("{\"cmd\":\"custom_text\",\"data\":[[255,129,129,129,129,129,129,255]],\"slot\":1}");
Serial.println("{\"cmd\":\"text\",\"value\":\"slot0\"}");
Serial.println("{\"cmd\":\"fan\",\"value\":128}");
Serial.println("---");
}
void loop() {
static uint16_t t = 0; // 改用 uint16_t,避免溢出导致重置
static String serialBuffer = "";
// 串口命令接收(用于 Wokwi 调试)
while (Serial.available()) {
char c = Serial.read();
if (c == '\n' || c == '\r') {
if (serialBuffer.length() > 0) {
Serial.print("串口收到: ");
Serial.println(serialBuffer);
processCommand(serialBuffer);
serialBuffer = "";
}
} else {
serialBuffer += c;
}
}
checkModeButton();
checkTextButton();
checkWiFiButton();
// 处理 HTTP 请求(如果 WiFi 已启用)
if (wifiEnabled) {
server.handleClient();
}
// 先渲染背景动画
switch (currentMode) {
case MODE_RAINBOW:
modeRainbow(t & 0xFF); // 只传低 8 位给动画
break;
case MODE_BREATHE:
modeBreathe(t & 0xFF);
break;
case MODE_CHASE:
modeChase(t & 0xFF);
break;
case MODE_WAVE:
modeWave(t & 0xFF);
break;
case MODE_SCANNER:
modeScanner(t & 0xFF);
break;
case MODE_GRADIENT:
modeGradient(t & 0xFF);
break;
}
// 叠加文字(如果启用)
overlayText(t); // 文字滚动用完整的 16 位计数
strip.show();
t++;
delay(frameDelay);
}