系统提示 // 建议使用电脑访问,桌面模式效果最佳
ESP32 + RGBWW FCOB 灯带网页色环控制

ESP32 + RGBWW FCOB 灯带网页色环控制

WiFi 网页色环与 RGB 滑块实时控制 RGBWW LED 灯带,含 RGB→RGBW 白光提取算法。

年份

2026年7月

类型

lab

分类

嵌入式系统

工具

ESP32 · RGBWW FCOB LED 灯带 · NeoPixelBus · WebServer

#ESP32#WiFi#NeoPixel#Web Server

灯带点亮效果

概述

前面两个实验攒的东西在这里汇合了。FCOB 灯带已经能跟 ESP32 正常通信,WiFi 网页服务器也知道怎么搭了。下一步很自然——做一个像样的取色器。不是几个 RGB 滑块凑合一下,而是能拖拽的真色环,灯带颜色实时跟着变。

网页界面最后成了整个项目里工作量最大的部分。我想让它看起来精致一点——深色/浅色模式切换、毛玻璃卡片、几个动画场景预设、颜色历史网格,还有页面背景会根据灯带当前颜色发光的氛围效果。用 <canvas> 从零画一个 HSV 色环确实是有点杀鸡用牛刀,但拖起来比三个滑块爽太多了。

颜色处理——RGB 转 RGBW

RGBWW 灯带的关键在于提取白光分量:

void updateLED() {
    byte W = min(R, min(G, B));   // 白光 = 三通道共同分量
    byte r = R - W;                // 纯红
    byte g = G - W;                // 纯绿
    byte b = B - W;                // 纯蓝

    for (int i = 0; i < LED_COUNT; i++) {
        strip.SetPixelColor(i, RgbwColor(r, W, g, b));
    }
    strip.Show();
}

注意: NeoGrbwFeature 的通道顺序为 红、白、绿、蓝——并非通常的 R、G、B、W。

想通了之后这个白光提取其实特别简单。任何 R、G、B 三个通道都有值的颜色——灰色、粉彩、暖色调——里面都包含了一个“白光分量”,大小等于三个通道中的最小值。把这个分量减出来,交给专门的暖白灯珠去发光,剩下的 RGB 值就是你想要的那个纯色调。不拆开的话,像 (255, 200, 150) 这种颜色看起来就会发灰发白,因为 RGB 灯珠同时要兼顾色调和白光——LED 干这个并不擅长。拆开之后颜色干净很多,肉眼可见的差别。

坑在通道顺序:NeoGrbwFeature 映射的是(红、白、绿、蓝),跟你从名字上猜的完全不是一回事。我在这上面浪费了整整 20 分钟,最后老老实实回去翻了库的文档才搞明白。

网页界面

色环取色器 RGB 滑块

网页提供:

  • 色环直观选色
  • R、G、B 独立滑块精细调节
  • LED 颜色实时预览

色环是在 <canvas> 上逐像素绘制的——外圈是色相环,用来选基础色调;中间是饱和度/明度三角,用来微调。在外圈拖动改变色相,在三角里拖动改变饱和度和明度。操作逻辑跟桌面端正经取色器一样,只不过它跑在一个单片机的浏览器里。

我还加了五个动画场景预设(日落、海洋、天空、森林、霓虹),用正弦波持续改变颜色。霓虹那个就是全色相循环,速度随便调。点一下场景按钮开始动画,再点一下停止。用来演示灯带效果很方便,不用一直拖着色环转。

整页用 CSS 自定义属性做主题,深色/浅色切换只需要改一套变量。字体栈用的是 Apple 的系统字体,外观走了毛玻璃风格——反正都要做取色器了,不如做得好看点。

完整代码

#include <WiFi.h>
#include <WebServer.h>
#include <NeoPixelBus.h>

// 修改为你的 WiFi 名称
const char* ssid = "YOUR_WIFI_USERNAME_HERE";
// 修改为你的 WiFi 密码
const char* password = "YOUR_WIFI_PASSWORD_HERE";

#define LED_PIN 5
#define LED_COUNT 150

// ★ 使用 NeoGrbwFeature,颜色顺序为 红、白、绿、蓝
NeoPixelBus<NeoGrbwFeature, NeoEsp32Rmt0800KbpsMethod> strip(LED_COUNT, LED_PIN);

WebServer server(80);

int R = 255, G = 0, B = 0;

// ★ 正确的 LED 更新算法(提取白光,重映射通道)
void updateLED() {
    byte W = min(R, min(G, B));
    byte r = R - W;
    byte g = G - W;
    byte b = B - W;

    for (int i = 0; i < LED_COUNT; i++) {
        strip.SetPixelColor(i, RgbwColor(r, W, g, b));
    }
    strip.Show();
}

void setRGB(String value) {
    int p1 = value.indexOf(',');
    int p2 = value.indexOf(',', p1 + 1);
    R = value.substring(0, p1).toInt();
    G = value.substring(p1 + 1, p2).toInt();
    B = value.substring(p2 + 1).toInt();
    Serial.printf("RGB:%d,%d,%d\n", R, G, B);
    updateLED();
}

void setup() {
    Serial.begin(115200);
    strip.Begin();
    strip.Show();

    WiFi.begin(ssid, password);
    while (WiFi.status() != WL_CONNECTED) {
        delay(500);
        Serial.print(".");
    }
    Serial.println();
    Serial.println(WiFi.localIP());

    server.on("/", []() {
        String html = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
    <title>RGBW 控制器</title>
    <style>
        :root {
            --bg: #000;
            --text: #f5f5f7;
            --card-bg: rgba(28,28,30,0.65);
            --slider-track: #3a3a3c;
            --slider-thumb: #0a84ff;
            --border: rgba(255,255,255,0.15);
            --shadow: rgba(0,0,0,0.5);
            --glass-blur: blur(30px);
        }
        body.light {
            --bg: #f2f2f7;
            --text: #1c1c1e;
            --card-bg: rgba(255,255,255,0.7);
            --slider-track: #c7c7cc;
            --slider-thumb: #007aff;
            --border: rgba(0,0,0,0.1);
            --shadow: rgba(0,0,0,0.08);
        }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
            background-color: var(--bg);
            color: var(--text);
            margin: 0;
            padding: 20px;
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            transition: background-color 0.3s ease, color 0.3s ease;
            position: relative;
            overflow-x: hidden;
        }
        #bg-glow {
            position: fixed;
            top: 0; left: 0;
            width: 100vw; height: 100vh;
            pointer-events: none;
            z-index: 0;
            background: radial-gradient(circle at 50% 50%, rgba(255,0,0,0.5) 0%, transparent 70%);
            transition: background 0.8s ease;
            filter: blur(80px);
            opacity: 0.8;
        }
        .container {
            position: relative;
            z-index: 2;
            width: 100%;
            max-width: 900px;
            background: var(--card-bg);
            backdrop-filter: var(--glass-blur);
            -webkit-backdrop-filter: var(--glass-blur);
            border-radius: 32px;
            padding: 30px 28px;
            box-shadow: 0 20px 50px var(--shadow);
            display: flex;
            flex-wrap: wrap;
            gap: 24px;
            transition: background 0.3s, box-shadow 0.3s;
            border: 1px solid var(--border);
        }
        .left-panel {
            flex: 1 1 420px;
            min-width: 300px;
            display: flex;
            flex-direction: column;
            align-items: center;
        }
        .right-panel {
            flex: 0 1 260px;
            display: flex;
            flex-direction: column;
            gap: 16px;
        }
        .header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            width: 100%;
            margin-bottom: 8px;
        }
        h1 {
            font-size: 28px;
            font-weight: 600;
            margin: 0;
            letter-spacing: -0.5px;
        }
        .theme-toggle {
            background: rgba(255,255,255,0.15);
            border: none;
            border-radius: 50%;
            width: 44px;
            height: 44px;
            font-size: 22px;
            display: flex;
            align-items: center;
            justify-content: center;
            cursor: pointer;
            color: var(--text);
            transition: background 0.2s, transform 0.2s;
            backdrop-filter: blur(10px);
            -webkit-backdrop-filter: blur(10px);
            line-height: 1;
        }
        .theme-toggle:hover {
            background: rgba(255,255,255,0.25);
            transform: scale(1.08);
        }
        canvas {
            display: block;
            border-radius: 20px;
            background: transparent;
            touch-action: none;
            margin-bottom: 16px;
        }
        .slider-group {
            width: 100%;
            margin: 6px 0;
        }
        .slider-group label {
            display: flex;
            align-items: center;
            justify-content: space-between;
            font-size: 15px;
            font-weight: 500;
            gap: 8px;
        }
        .slider-group span {
            min-width: 36px;
            text-align: right;
            font-weight: 600;
        }
        input[type="range"] {
            -webkit-appearance: none;
            appearance: none;
            width: 100%;
            height: 6px;
            border-radius: 3px;
            background: var(--slider-track);
            outline: none;
            margin: 8px 0;
        }
        input[type="range"]::-webkit-slider-thumb {
            -webkit-appearance: none;
            appearance: none;
            width: 24px;
            height: 24px;
            border-radius: 50%;
            background: var(--slider-thumb);
            border: 2px solid rgba(255,255,255,0.3);
            cursor: pointer;
            box-shadow: 0 2px 10px rgba(0,0,0,0.3);
            transition: transform 0.15s ease;
        }
        .scene-buttons {
            display: flex;
            flex-wrap: wrap;
            gap: 10px;
        }
        .scene-btn {
            background: rgba(255,255,255,0.1);
            backdrop-filter: blur(15px);
            -webkit-backdrop-filter: blur(15px);
            border: 1px solid rgba(255,255,255,0.2);
            border-radius: 18px;
            padding: 12px 10px;
            color: var(--text);
            font-size: 16px;
            font-weight: 500;
            cursor: pointer;
            display: flex;
            align-items: center;
            gap: 8px;
            transition: all 0.25s ease;
            flex: 1 1 calc(50% - 10px);
            justify-content: center;
            letter-spacing: 0.3px;
        }
        .scene-btn:hover {
            background: rgba(255,255,255,0.2);
            border-color: rgba(255,255,255,0.4);
            transform: translateY(-2px);
            box-shadow: 0 8px 20px rgba(0,0,0,0.3);
        }
        .scene-btn.active {
            background: var(--slider-thumb);
            border-color: var(--slider-thumb);
            color: white;
            box-shadow: 0 0 20px var(--slider-thumb);
        }
        .speed-slider {
            margin-top: 8px;
        }
        .color-history {
            margin-top: 16px;
        }
        .history-title {
            font-size: 14px;
            font-weight: 600;
            margin-bottom: 8px;
            opacity: 0.8;
        }
        .history-grid {
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
        }
        .history-swatch {
            width: 32px;
            height: 32px;
            border-radius: 10px;
            border: 2px solid rgba(255,255,255,0.25);
            cursor: pointer;
            transition: transform 0.15s, border-color 0.2s;
            backdrop-filter: blur(5px);
            -webkit-backdrop-filter: blur(5px);
        }
        .history-swatch:hover {
            transform: scale(1.15);
            border-color: rgba(255,255,255,0.6);
        }
        #info {
            font-size: 13px;
            margin-top: 12px;
            opacity: 0.7;
            text-align: center;
            font-weight: 500;
        }
        @media (max-width: 750px) {
            .container {
                flex-direction: column;
                align-items: center;
            }
            .right-panel {
                width: 100%;
                flex: unset;
            }
        }
    </style>
</head>
<body class="dark">
    <div id="bg-glow"></div>
    <div class="container">
        <div class="left-panel">
            <div class="header">
                <h1>RGBW 控制</h1>
                <button class="theme-toggle" id="theme-toggle" title="切换深色/浅色模式">☀️</button>
            </div>
            <canvas id="wheel" width="380" height="380"></canvas>
            <div class="slider-group">
                <label>色调 <input id="h" type="range" min="0" max="360" value="0"> <span id="h-val">0</span></label>
            </div>
            <div class="slider-group">
                <label>饱和度 <input id="s" type="range" min="0" max="100" value="100"> <span id="s-val">100</span></label>
            </div>
            <div class="slider-group">
                <label>明度 <input id="v" type="range" min="0" max="100" value="100"> <span id="v-val">100</span></label>
            </div>
            <div id="info">RGB: 255,0,0</div>
        </div>
        <div class="right-panel">
            <div style="font-weight:600; font-size:16px; margin-bottom:4px;">场景</div>
            <div class="scene-buttons" id="scene-buttons">
                <button class="scene-btn" data-scene="sunset">🌅 日落</button>
                <button class="scene-btn" data-scene="ocean">🌊 海洋</button>
                <button class="scene-btn" data-scene="sky">☁️ 天空</button>
                <button class="scene-btn" data-scene="forest">🌿 森林</button>
                <button class="scene-btn" data-scene="neon">💜 霓虹</button>
            </div>
            <div class="speed-slider">
                <label style="font-size:14px; font-weight:500;">速度 <span id="speed-val">800ms</span></label>
                <input id="speed" type="range" min="100" max="2000" value="800" step="10">
            </div>
            <div class="color-history">
                <div class="history-title">历史</div>
                <div class="history-grid" id="history-grid"></div>
            </div>
        </div>
    </div>

    <script>
        // ========== 主题切换 ==========
        const body = document.body;
        const themeToggle = document.getElementById('theme-toggle');
        const savedTheme = localStorage.getItem('theme');
        if (savedTheme === 'light') {
            body.classList.remove('dark'); body.classList.add('light');
            themeToggle.innerHTML = '🌙';
        } else {
            body.classList.add('dark'); body.classList.remove('light');
            themeToggle.innerHTML = '☀️';
        }
        themeToggle.addEventListener('click', () => {
            if (body.classList.contains('dark')) {
                body.classList.remove('dark'); body.classList.add('light');
                themeToggle.innerHTML = '🌙';
                localStorage.setItem('theme', 'light');
            } else {
                body.classList.remove('light'); body.classList.add('dark');
                themeToggle.innerHTML = '☀️';
                localStorage.setItem('theme', 'dark');
            }
        });

        // ========== 全局变量 ==========
        let H = 0, S = 1, V = 1;
        const canvas = document.getElementById("wheel");
        const ctx = canvas.getContext("2d");
        const W = canvas.width, H_canvas = canvas.height;
        const CX = W/2, CY = H_canvas/2;
        const RING_OUTER = 175;
        const RING_INNER = 120;
        const SV_SIZE = 140;
        const SV_X0 = CX - SV_SIZE/2;
        const SV_Y0 = CY - SV_SIZE/2;
        let dragging = null;
        let animationId = null;          // requestAnimationFrame ID
        let lastSendTime = 0;           // 上次发送请求的时间戳
        const SEND_THROTTLE = 50;       // 50ms 内最多发一次请求
        let activeScene = null;
        const colorHistory = [];
        const MAX_HISTORY = 15;

        // ========== HSV/RGB 转换 ==========
        function hsvToRgb(h, s, v) {
            let c = v * s;
            let x = c * (1 - Math.abs((h / 60) % 2 - 1));
            let m = v - c;
            let r = 0, g = 0, b = 0;
            if (h < 60) { r = c; g = x; }
            else if (h < 120) { r = x; g = c; }
            else if (h < 180) { g = c; b = x; }
            else if (h < 240) { g = x; b = c; }
            else if (h < 300) { r = x; b = c; }
            else { r = c; b = x; }
            return [
                Math.round((r + m) * 255),
                Math.round((g + m) * 255),
                Math.round((b + m) * 255)
            ];
        }

        function rgbToHsv(r, g, b) {
            r /= 255; g /= 255; b /= 255;
            let max = Math.max(r,g,b), min = Math.min(r,g,b);
            let h, s, v = max;
            let d = max - min;
            s = max === 0 ? 0 : d / max;
            if (max === min) h = 0;
            else {
                switch (max) {
                    case r: h = (g - b) / d + (g < b ? 6 : 0); break;
                    case g: h = (b - r) / d + 2; break;
                    case b: h = (r - g) / d + 4; break;
                }
                h /= 6;
            }
            return { h: h * 360, s: s, v: v };
        }

        // ========== 背景发光更新 ==========
        function updateGlow(rgb) {
            document.getElementById('bg-glow').style.background =
                `radial-gradient(circle at 50% 50%, rgba(${rgb[0]},${rgb[1]},${rgb[2]},0.45) 0%, transparent 70%)`;
        }

        // ========== 绘制 ==========
        function drawBase() {
            let img = ctx.createImageData(W, H_canvas);
            for (let y = 0; y < H_canvas; y++) {
                for (let x = 0; x < W; x++) {
                    let dx = x - CX, dy = y - CY;
                    let r = Math.sqrt(dx*dx + dy*dy);
                    if (r >= RING_INNER && r <= RING_OUTER) {
                        let angle = Math.atan2(dx, -dy);
                        let hue = (angle * 180 / Math.PI + 360) % 360;
                        let rgb = hsvToRgb(hue, 1, 1);
                        let i = (y * W + x) * 4;
                        img.data[i] = rgb[0]; img.data[i+1] = rgb[1]; img.data[i+2] = rgb[2]; img.data[i+3] = 255;
                    }
                }
            }
            for (let y = 0; y < SV_SIZE; y++) {
                for (let x = 0; x < SV_SIZE; x++) {
                    let px = SV_X0 + x, py = SV_Y0 + y;
                    if (px < 0 || px >= W || py < 0 || py >= H_canvas) continue;
                    let sat = x / SV_SIZE;
                    let val = 1 - y / SV_SIZE;
                    let rgb = hsvToRgb(H, sat, val);
                    let i = (py * W + px) * 4;
                    img.data[i] = rgb[0]; img.data[i+1] = rgb[1]; img.data[i+2] = rgb[2]; img.data[i+3] = 255;
                }
            }
            ctx.putImageData(img, 0, 0);
        }

        function drawIndicators() {
            let midR = (RING_INNER + RING_OUTER) / 2;
            let radH = H * Math.PI / 180;
            let wx = CX + midR * Math.sin(radH);
            let wy = CY - midR * Math.cos(radH);
            ctx.beginPath(); ctx.arc(wx, wy, 5, 0, 2*Math.PI);
            ctx.fillStyle = "white"; ctx.fill();
            ctx.strokeStyle = "black"; ctx.lineWidth = 2; ctx.stroke();

            let svx = SV_X0 + S * SV_SIZE;
            let svy = SV_Y0 + (1 - V) * SV_SIZE;
            ctx.beginPath(); ctx.arc(svx, svy, 6, 0, 2*Math.PI);
            ctx.fillStyle = hsvToRgb(H, S, V)[0] > 128 ? "black" : "white";
            ctx.fill();
            ctx.strokeStyle = "white"; ctx.lineWidth = 2.5; ctx.stroke();
            ctx.beginPath(); ctx.arc(svx, svy, 4, 0, 2*Math.PI);
            ctx.strokeStyle = "black"; ctx.lineWidth = 1.2; ctx.stroke();
        }

        function drawAll() { drawBase(); drawIndicators(); }

        // ========== 网络发送 ==========
        function send() {
            let rgb = hsvToRgb(H, S, V);
            document.getElementById("info").innerHTML = "RGB: " + rgb.join(",");
            fetch("/rgb?value=" + rgb.join(","));
            updateGlow(rgb);
        }

        function updateSliders() {
            document.getElementById("h").value = H;
            document.getElementById("s").value = Math.round(S * 100);
            document.getElementById("v").value = Math.round(V * 100);
            document.getElementById("h-val").textContent = Math.round(H);
            document.getElementById("s-val").textContent = Math.round(S * 100);
            document.getElementById("v-val").textContent = Math.round(V * 100);
        }

        // ========== 坐标修正 ==========
        function getCanvasCoords(e) {
            let rect = canvas.getBoundingClientRect();
            let scaleX = canvas.width / rect.width;
            let scaleY = canvas.height / rect.height;
            return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
        }

        // ========== 交互事件 ==========
        function handleDown(e) {
            e.preventDefault();
            stopAnimation();
            let pos = getCanvasCoords(e);
            let dx = pos.x - CX, dy = pos.y - CY;
            let r = Math.sqrt(dx*dx + dy*dy);
            if (r >= RING_INNER && r <= RING_OUTER) {
                dragging = 'wheel';
                setHueFromPos(pos);
            } else if (r < RING_INNER) {
                if (pos.x >= SV_X0 && pos.x <= SV_X0 + SV_SIZE && pos.y >= SV_Y0 && pos.y <= SV_Y0 + SV_SIZE) {
                    dragging = 'sv';
                    setSVFromPos(pos);
                }
            }
            drawAll(); updateSliders(); send();
        }

        function handleMove(e) {
            if (!dragging) return;
            e.preventDefault();
            let pos = getCanvasCoords(e);
            if (dragging === 'wheel') setHueFromPos(pos);
            else if (dragging === 'sv') setSVFromPos(pos);
            drawAll(); updateSliders(); send();
        }

        function handleUp(e) {
            if (dragging) {
                addColorToHistory(hsvToRgb(H, S, V));
            }
            dragging = null;
        }

        function setHueFromPos(pos) {
            let dx = pos.x - CX, dy = pos.y - CY;
            let angle = Math.atan2(dx, -dy);
            H = (angle * 180 / Math.PI + 360) % 360;
        }

        function setSVFromPos(pos) {
            S = (pos.x - SV_X0) / SV_SIZE;
            V = 1 - (pos.y - SV_Y0) / SV_SIZE;
            S = Math.min(1, Math.max(0, S));
            V = Math.min(1, Math.max(0, V));
        }

        // 触摸事件
        function handleTouchStart(e) {
            e.preventDefault();
            stopAnimation();
            if (e.touches.length === 1) {
                const t = e.touches[0];
                handleDown({ clientX: t.clientX, clientY: t.clientY, preventDefault: ()=>{} });
            }
        }
        function handleTouchMove(e) {
            if (!dragging) return;
            e.preventDefault();
            if (e.touches.length === 1) {
                const t = e.touches[0];
                handleMove({ clientX: t.clientX, clientY: t.clientY, preventDefault: ()=>{} });
            }
        }
        function handleTouchEnd(e) { handleUp(); }

        canvas.addEventListener('mousedown', handleDown);
        document.addEventListener('mousemove', handleMove);
        document.addEventListener('mouseup', handleUp);
        canvas.addEventListener('touchstart', handleTouchStart, { passive: false });
        document.addEventListener('touchmove', handleTouchMove, { passive: false });
        document.addEventListener('touchend', handleTouchEnd);

        // 滑块事件(手动修改时停止动画)
        ['h','s','v'].forEach(id => {
            document.getElementById(id).addEventListener('input', function() {
                stopAnimation();
                if (id === 'h') H = Number(this.value);
                else if (id === 's') S = Number(this.value)/100;
                else if (id === 'v') V = Number(this.value)/100;
                drawAll(); updateSliders(); send();
            });
            document.getElementById(id).addEventListener('change', function() {
                addColorToHistory(hsvToRgb(H, S, V));
            });
        });

        // ========== 动画控制 ==========
        function stopAnimation() {
            if (animationId) {
                cancelAnimationFrame(animationId);
                animationId = null;
            }
            document.querySelectorAll('.scene-btn').forEach(btn => btn.classList.remove('active'));
            activeScene = null;
        }

        function startScene(scene) {
            stopAnimation();
            activeScene = scene;
            document.querySelector(`.scene-btn[data-scene="${scene}"]`).classList.add('active');
            const speed = parseInt(document.getElementById('speed').value) / 1000; // 转为秒,用于数学频率

            function frame() {
                const t = performance.now() / 1000;
                let h, s, v;
                switch (scene) {
                    case 'sunset':
                        h = 30 + 15 * Math.sin(t * speed * 0.5);
                        s = 0.9 + 0.1 * Math.sin(t * speed * 0.7);
                        v = 0.7 + 0.3 * Math.cos(t * speed * 0.4);
                        break;
                    case 'ocean':
                        h = 200 + 20 * Math.sin(t * speed * 0.8);
                        s = 0.7 + 0.3 * Math.sin(t * speed * 1.2);
                        v = 0.6 + 0.4 * Math.abs(Math.sin(t * speed * 1.5));
                        break;
                    case 'sky':
                        h = 210 + 15 * Math.sin(t * speed * 0.3);
                        s = 0.5 + 0.2 * Math.sin(t * speed * 0.6);
                        v = 0.9 + 0.1 * Math.cos(t * speed * 0.2);
                        break;
                    case 'forest':
                        h = 120 + 15 * Math.sin(t * speed * 0.6);
                        s = 0.6 + 0.3 * Math.sin(t * speed * 0.9);
                        v = 0.5 + 0.3 * Math.abs(Math.sin(t * speed * 1.1));
                        break;
                    case 'neon':
                        h = (t * speed * 60) % 360;
                        s = 1;
                        v = 0.8 + 0.2 * Math.sin(t * speed * 2);
                        break;
                }
                H = h % 360; S = Math.min(1, Math.max(0, s)); V = Math.min(1, Math.max(0, v));
                drawAll();
                updateSliders();

                const now = performance.now();
                if (now - lastSendTime > SEND_THROTTLE) {
                    send();
                    lastSendTime = now;
                }

                animationId = requestAnimationFrame(frame);
            }

            lastSendTime = 0;
            frame();
        }

        // 场景按钮
        document.querySelectorAll('.scene-btn').forEach(btn => {
            btn.addEventListener('click', () => {
                const scene = btn.dataset.scene;
                if (activeScene === scene) {
                    stopAnimation();
                } else {
                    startScene(scene);
                }
            });
        });

        // 速度滑块
        document.getElementById('speed').addEventListener('input', function() {
            document.getElementById('speed-val').textContent = this.value + 'ms';
            // 动画会实时读取 speed 值,无需重启动画
        });

        // ========== 色彩历史 ==========
        function addColorToHistory(rgb) {
            if (colorHistory.length > 0) {
                let last = colorHistory[colorHistory.length-1];
                if (last[0]===rgb[0] && last[1]===rgb[1] && last[2]===rgb[2]) return;
            }
            colorHistory.push([...rgb]);
            if (colorHistory.length > MAX_HISTORY) colorHistory.shift();
            renderHistory();
        }

        function renderHistory() {
            const grid = document.getElementById('history-grid');
            grid.innerHTML = '';
            colorHistory.forEach((rgb, idx) => {
                const swatch = document.createElement('div');
                swatch.className = 'history-swatch';
                swatch.style.background = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
                swatch.addEventListener('click', () => {
                    stopAnimation();
                    const { h, s, v } = rgbToHsv(rgb[0], rgb[1], rgb[2]);
                    H = h; S = s; V = v;
                    drawAll(); updateSliders(); send();
                });
                grid.appendChild(swatch);
            });
        }

        // 初始化
        drawAll();
        updateSliders();
        send();
        updateGlow(hsvToRgb(H, S, V));
    </script>
</body>
</html>
)rawliteral";
        server.send(200, "text/html;charset=utf-8", html);
    });

    server.on("/rgb", []() {
        if (server.hasArg("value")) {
            setRGB(server.arg("value"));
        }
        server.send(200, "text/plain", "OK");
    });

    server.begin();
}

void loop() {
    server.handleClient();
}

结果

ESP32 成功提供响应式颜色控制页面。用户可通过色环或独立通道调节颜色,FCOB 灯带即时响应。RGB→RGBW 算法输出色彩干净,暖白通道可独立控制。

这种项目属于硬件没几样、但成品看起来特别像回事的类型——就一块 ESP32 加一条灯带,但网页界面一打开,感觉就像个正经产品。色环拖起来很解压,场景动画演示效果也好,白光提取让粉彩和暖色调看起来是那个味道而不是灰蒙蒙的。后面还想接着搞——多加几个场景、接 MQTT、看看能不能挂到 Home Assistant 上。不过现阶段它已经做到了我最开始想要的效果:选个颜色,灯带亮起来,不用装任何 App。