/* * JeLamp_C3_Slave_Web.ino * Board 2: XIAO ESP32-C3 — I2C slave + NeoPixel + Wi-Fi web config UI * * Wiring: * I2C SDA(D4) GPIO6, SCL(D5) GPIO7 (from ESP32-S3 master) * NeoPixel DIN -> D3, 8 LEDs * * Wi-Fi AP: auto-generated RPS-XXXX SSID/password from MAC * Web UI: http://192.168.4.1 (captive portal) * * SenseCraft class targets: * target=0 -> Paper (bu) * target=1 -> Stone (shitou) * * Companion: JeLamp_S3_Master.ino on ESP32-S3 Sense * Requires: types.h in same folder */ #include "types.h" #include #include #include #include #include #include #include // ==================== 硬件配置 ==================== #define NEOPIXEL_PIN D3 #define NUM_PIXELS 8 #define I2C_SLAVE_ADDR 0x08 // ==================== 自动生成的身份信息 ==================== String deviceID; String apSSID; String apPassword; String apIP_str; void generateDeviceIdentity() { uint8_t mac[6]; WiFi.macAddress(mac); char idBuf[16]; snprintf(idBuf, sizeof(idBuf), "RPS-%02X%02X", mac[4], mac[5]); deviceID = String(idBuf); apSSID = deviceID; char pwBuf[32]; snprintf(pwBuf, sizeof(pwBuf), "rps-%02x%02x-%02x%02x", mac[2], mac[3], mac[4], mac[5]); apPassword = String(pwBuf); apIP_str = "192.168.4.1"; } // ==================== NeoPixel ==================== Adafruit_NeoPixel strip(NUM_PIXELS, NEOPIXEL_PIN, NEO_GRB + NEO_KHZ800); // ==================== DNS + Web ==================== DNSServer dnsServer; WebServer server(80); // ==================== 协议定义 ==================== #define MSG_BOX 0x01 #define MSG_CLASS 0x02 #define MSG_POINT 0x03 #define MSG_PERF 0xFF // ==================== 类别定义 ==================== #define TARGET_PAPER 0 #define TARGET_STONE 1 // ==================== 配置实例 ==================== GestureConfig paperCfg = { 0, 120, 255, PATTERN_WAVE, 5 }; GestureConfig stoneCfg = { 255, 40, 0, PATTERN_PULSE, 7 }; IdleConfig idleCfg = { 20, 20, 15, 3 }; // ==================== 持久化 ==================== Preferences prefs; void saveConfig() { prefs.begin("lightcfg", false); prefs.putUChar("pr", paperCfg.r); prefs.putUChar("pg", paperCfg.g); prefs.putUChar("pb", paperCfg.b); prefs.putUChar("pp", (uint8_t)paperCfg.pattern); prefs.putUChar("ps", paperCfg.speed); prefs.putUChar("sr", stoneCfg.r); prefs.putUChar("sg", stoneCfg.g); prefs.putUChar("sb", stoneCfg.b); prefs.putUChar("sp", (uint8_t)stoneCfg.pattern); prefs.putUChar("ss", stoneCfg.speed); prefs.putUChar("ir", idleCfg.r); prefs.putUChar("ig", idleCfg.g); prefs.putUChar("ib", idleCfg.b); prefs.putUChar("is", idleCfg.speed); prefs.end(); } void loadConfig() { prefs.begin("lightcfg", true); if (prefs.isKey("pr")) { paperCfg.r = prefs.getUChar("pr", 0); paperCfg.g = prefs.getUChar("pg", 120); paperCfg.b = prefs.getUChar("pb", 255); paperCfg.pattern = (PatternType)prefs.getUChar("pp", PATTERN_WAVE); paperCfg.speed = prefs.getUChar("ps", 5); stoneCfg.r = prefs.getUChar("sr", 255); stoneCfg.g = prefs.getUChar("sg", 40); stoneCfg.b = prefs.getUChar("sb", 0); stoneCfg.pattern = (PatternType)prefs.getUChar("sp", PATTERN_PULSE); stoneCfg.speed = prefs.getUChar("ss", 7); idleCfg.r = prefs.getUChar("ir", 20); idleCfg.g = prefs.getUChar("ig", 20); idleCfg.b = prefs.getUChar("ib", 15); idleCfg.speed = prefs.getUChar("is", 3); } prefs.end(); } // ==================== 接收数据 ==================== #define RX_BUF_SIZE 32 volatile uint8_t rxBuf[RX_BUF_SIZE]; volatile uint8_t rxLen = 0; volatile bool newData = false; volatile int8_t currentTarget = -1; volatile int16_t currentScore = 0; volatile uint8_t currentMsgType = 0; unsigned long lastMsgTime = 0; #define IDLE_TIMEOUT 2000 // ==================== I2C 接收回调 ==================== void onReceive(int numBytes) { rxLen = 0; while (Wire.available() && rxLen < RX_BUF_SIZE) { rxBuf[rxLen++] = Wire.read(); } if (rxLen < 1) return; currentMsgType = rxBuf[0]; switch (currentMsgType) { case MSG_BOX: if (rxLen >= 12) { currentScore = (int16_t)((rxBuf[9] << 8) | rxBuf[10]); currentTarget = (int8_t)rxBuf[11]; newData = true; lastMsgTime = millis(); } break; case MSG_CLASS: if (rxLen >= 4) { currentScore = (int16_t)((rxBuf[1] << 8) | rxBuf[2]); currentTarget = (int8_t)rxBuf[3]; newData = true; lastMsgTime = millis(); } break; case MSG_POINT: if (rxLen >= 10) { currentScore = (int16_t)((rxBuf[7] << 8) | rxBuf[8]); currentTarget = (int8_t)rxBuf[9]; newData = true; lastMsgTime = millis(); } break; default: break; } } // ==================== 灯光效果引擎 ==================== void patternWave(uint8_t r, uint8_t g, uint8_t b, uint8_t speed, int16_t score) { static float phase = 0; float brightness = constrain(score, 30, 100) / 100.0f; float spd = speed / 50.0f; for (int i = 0; i < NUM_PIXELS; i++) { float wave = (sin(phase + i * 0.8f) + 1.0f) / 2.0f; wave = wave * 0.7f + 0.3f; strip.setPixelColor(i, strip.Color( (uint8_t)(r * wave * brightness), (uint8_t)(g * wave * brightness), (uint8_t)(b * wave * brightness))); } strip.show(); phase += spd; } void patternPulse(uint8_t r, uint8_t g, uint8_t b, uint8_t speed, int16_t score) { static uint8_t pulseVal = 0; static int16_t pulseDir = 5; static uint8_t flashCount = 0; static bool inFlash = true; static unsigned long stableStart = 0; float brightness = constrain(score, 30, 100) / 100.0f; int16_t step = speed; if (inFlash) { pulseVal = constrain((int16_t)pulseVal + pulseDir, 0, 255); if (pulseVal >= 250) { pulseDir = -(step + 3); } if (pulseVal <= 10) { pulseDir = step + 3; flashCount++; if (flashCount >= 3) { inFlash = false; flashCount = 0; stableStart = millis(); } } } else { pulseVal = 200; if (millis() - stableStart > (uint32_t)(1200 - speed * 80)) { inFlash = true; stableStart = 0; } } uint8_t pr = (uint8_t)((r / 255.0f) * pulseVal * brightness); uint8_t pg = (uint8_t)((g / 255.0f) * pulseVal * brightness); uint8_t pb = (uint8_t)((b / 255.0f) * pulseVal * brightness); for (int i = 0; i < NUM_PIXELS; i++) { strip.setPixelColor(i, strip.Color(pr, pg, pb)); } strip.show(); } void patternBreathe(uint8_t r, uint8_t g, uint8_t b, uint8_t speed, int16_t score) { static float phase = 0; float brightness = constrain(score, 30, 100) / 100.0f; float spd = speed / 80.0f; float val = (sin(phase) + 1.0f) / 2.0f; val = val * 0.85f + 0.15f; for (int i = 0; i < NUM_PIXELS; i++) { strip.setPixelColor(i, strip.Color( (uint8_t)(r * val * brightness), (uint8_t)(g * val * brightness), (uint8_t)(b * val * brightness))); } strip.show(); phase += spd; } void patternChase(uint8_t r, uint8_t g, uint8_t b, uint8_t speed, int16_t score) { static uint8_t pos = 0; static unsigned long lastUpdate = 0; float brightness = constrain(score, 30, 100) / 100.0f; unsigned long interval = 200 - speed * 18; if (millis() - lastUpdate > interval) { lastUpdate = millis(); strip.clear(); for (int i = 0; i < NUM_PIXELS; i++) { int dist = (i - pos + NUM_PIXELS) % NUM_PIXELS; float fade = 0.0f; if (dist == 0) fade = 1.0f; else if (dist == 1 || dist == NUM_PIXELS - 1) fade = 0.4f; else if (dist == 2 || dist == NUM_PIXELS - 2) fade = 0.1f; if (fade > 0) { strip.setPixelColor(i, strip.Color( (uint8_t)(r * fade * brightness), (uint8_t)(g * fade * brightness), (uint8_t)(b * fade * brightness))); } } strip.show(); pos = (pos + 1) % NUM_PIXELS; } } void patternSparkle(uint8_t r, uint8_t g, uint8_t b, uint8_t speed, int16_t score) { static unsigned long lastUpdate = 0; float brightness = constrain(score, 30, 100) / 100.0f; unsigned long interval = 150 - speed * 12; if (millis() - lastUpdate > interval) { lastUpdate = millis(); for (int i = 0; i < NUM_PIXELS; i++) { strip.setPixelColor(i, strip.Color( (uint8_t)(r * 0.15f * brightness), (uint8_t)(g * 0.15f * brightness), (uint8_t)(b * 0.15f * brightness))); } int sparkle = random(NUM_PIXELS); strip.setPixelColor(sparkle, strip.Color( (uint8_t)(r * brightness), (uint8_t)(g * brightness), (uint8_t)(b * brightness))); strip.show(); } } void patternSolid(uint8_t r, uint8_t g, uint8_t b, uint8_t speed, int16_t score) { float brightness = constrain(score, 30, 100) / 100.0f; for (int i = 0; i < NUM_PIXELS; i++) { strip.setPixelColor(i, strip.Color( (uint8_t)(r * brightness), (uint8_t)(g * brightness), (uint8_t)(b * brightness))); } strip.show(); } void runPattern(GestureConfig& cfg, int16_t score) { switch (cfg.pattern) { case PATTERN_WAVE: patternWave(cfg.r, cfg.g, cfg.b, cfg.speed, score); break; case PATTERN_PULSE: patternPulse(cfg.r, cfg.g, cfg.b, cfg.speed, score); break; case PATTERN_BREATHE: patternBreathe(cfg.r, cfg.g, cfg.b, cfg.speed, score); break; case PATTERN_CHASE: patternChase(cfg.r, cfg.g, cfg.b, cfg.speed, score); break; case PATTERN_SPARKLE: patternSparkle(cfg.r, cfg.g, cfg.b, cfg.speed, score); break; case PATTERN_SOLID: patternSolid(cfg.r, cfg.g, cfg.b, cfg.speed, score); break; } } void effectIdle() { static uint8_t val = 0; static int8_t dir = 1; int8_t step = max((int8_t)1, (int8_t)(idleCfg.speed / 3)); val = constrain((int16_t)val + dir * step, 0, 255); if (val >= 60) dir = -1; if (val <= 3) dir = 1; for (int i = 0; i < NUM_PIXELS; i++) { strip.setPixelColor(i, strip.Color( (uint8_t)((idleCfg.r / 255.0f) * val), (uint8_t)((idleCfg.g / 255.0f) * val), (uint8_t)((idleCfg.b / 255.0f) * val))); } strip.show(); } // ---------- 过渡动画 ---------- bool playTransition = false; uint8_t transStep = 0; int8_t transTarget = -1; void startTransition(int8_t target) { playTransition = true; transStep = 0; transTarget = target; } bool effectTransition() { if (!playTransition) return false; uint8_t r, g, b; if (transTarget == TARGET_PAPER) { r = paperCfg.r; g = paperCfg.g; b = paperCfg.b; } else { r = stoneCfg.r; g = stoneCfg.g; b = stoneCfg.b; } strip.clear(); int center1 = 3, center2 = 4; int spread = transStep / 3; for (int i = 0; i < NUM_PIXELS; i++) { int dist1 = abs(i - center1); int dist2 = abs(i - center2); int dist = min(dist1, dist2); if (dist <= spread) { strip.setPixelColor(i, strip.Color(r, g, b)); } } strip.show(); transStep++; if (transStep >= 20) { playTransition = false; return false; } return true; } // ==================== 网页 HTML ==================== String buildPageHTML() { String html = F(R"rawliteral( RPS Light Config

✊✋ Light Config

Rock Paper Scissors • NeoPixel Controller

Saved!
Device:---
WiFi:---
Pass:---
IP:---
Paper (Bu) TARGET 0
5
Stone (Shitou) TARGET 1
7
💤 Idle (No Detection) STANDBY
3
)rawliteral"); return html; } String cachedPage; // ==================== API 处理 ==================== void handleRoot() { server.send(200, "text/html", cachedPage); } void handleGetInfo() { String json = "{\"id\":\"" + deviceID + "\"," "\"ssid\":\"" + apSSID + "\"," "\"password\":\"" + apPassword + "\"," "\"ip\":\"" + apIP_str + "\"}"; server.send(200, "application/json", json); } void handleGetConfig() { String json = "{"; json += "\"paper\":{\"r\":" + String(paperCfg.r) + ",\"g\":" + String(paperCfg.g) + ",\"b\":" + String(paperCfg.b); json += ",\"pattern\":" + String((int)paperCfg.pattern) + ",\"speed\":" + String(paperCfg.speed) + "},"; json += "\"stone\":{\"r\":" + String(stoneCfg.r) + ",\"g\":" + String(stoneCfg.g) + ",\"b\":" + String(stoneCfg.b); json += ",\"pattern\":" + String((int)stoneCfg.pattern) + ",\"speed\":" + String(stoneCfg.speed) + "},"; json += "\"idle\":{\"r\":" + String(idleCfg.r) + ",\"g\":" + String(idleCfg.g) + ",\"b\":" + String(idleCfg.b); json += ",\"speed\":" + String(idleCfg.speed) + "}}"; server.send(200, "application/json", json); } void parseJsonConfig(const String& body) { auto extractSection = [](const String& b, const String& key, const String& nextKey) -> String { int start = b.indexOf("\"" + key + "\""); if (start < 0) return ""; int end = nextKey.length() > 0 ? b.indexOf("\"" + nextKey + "\"") : (int)b.length(); if (end < 0) end = b.length(); return b.substring(start, end); }; auto getField = [](const String& section, const String& field) -> int { int i = section.indexOf("\"" + field + "\""); if (i < 0) return -9999; i = section.indexOf(':', i); if (i < 0) return -9999; i++; while (i < (int)section.length() && section[i] == ' ') i++; String n = ""; while (i < (int)section.length() && (isDigit(section[i]) || section[i] == '-')) n += section[i++]; if (n.length() == 0) return -9999; return n.toInt(); }; String paperSec = extractSection(body, "paper", "stone"); String stoneSec = extractSection(body, "stone", "idle"); String idleSec = extractSection(body, "idle", ""); int v; if (paperSec.length() > 0) { v = getField(paperSec, "r"); if (v != -9999) paperCfg.r = v; v = getField(paperSec, "g"); if (v != -9999) paperCfg.g = v; v = getField(paperSec, "b"); if (v != -9999) paperCfg.b = v; v = getField(paperSec, "pattern"); if (v != -9999) paperCfg.pattern = (PatternType)v; v = getField(paperSec, "speed"); if (v != -9999) paperCfg.speed = v; } if (stoneSec.length() > 0) { v = getField(stoneSec, "r"); if (v != -9999) stoneCfg.r = v; v = getField(stoneSec, "g"); if (v != -9999) stoneCfg.g = v; v = getField(stoneSec, "b"); if (v != -9999) stoneCfg.b = v; v = getField(stoneSec, "pattern"); if (v != -9999) stoneCfg.pattern = (PatternType)v; v = getField(stoneSec, "speed"); if (v != -9999) stoneCfg.speed = v; } if (idleSec.length() > 0) { v = getField(idleSec, "r"); if (v != -9999) idleCfg.r = v; v = getField(idleSec, "g"); if (v != -9999) idleCfg.g = v; v = getField(idleSec, "b"); if (v != -9999) idleCfg.b = v; v = getField(idleSec, "speed"); if (v != -9999) idleCfg.speed = v; } } void handlePostConfig() { String body = server.arg("plain"); parseJsonConfig(body); server.send(200, "application/json", "{\"ok\":true}"); } void handleSave() { String body = server.arg("plain"); parseJsonConfig(body); saveConfig(); server.send(200, "application/json", "{\"ok\":true}"); Serial.println("Config saved to flash!"); } void handleReset() { paperCfg = { 0, 120, 255, PATTERN_WAVE, 5 }; stoneCfg = { 255, 40, 0, PATTERN_PULSE, 7 }; idleCfg = { 20, 20, 15, 3 }; saveConfig(); server.send(200, "application/json", "{\"ok\":true}"); Serial.println("Config reset to defaults"); } void handleTest() { int target = server.arg("target").toInt(); GestureConfig& cfg = (target == 0) ? paperCfg : stoneCfg; for (int f = 0; f < 3; f++) { for (int i = 0; i < NUM_PIXELS; i++) { strip.setPixelColor(i, strip.Color(cfg.r, cfg.g, cfg.b)); } strip.show(); delay(120); strip.clear(); strip.show(); delay(80); } server.send(200, "application/json", "{\"ok\":true}"); } void handleNotFound() { server.sendHeader("Location", "http://192.168.4.1/", true); server.send(302, "text/plain", ""); } void handleCaptivePortal() { server.sendHeader("Location", "http://192.168.4.1/", true); server.send(302, "text/plain", ""); } // ==================== Setup ==================== void setup() { Serial.begin(115200); delay(500); Serial.println("=== Rock Paper Scissors ==="); Serial.println("ESP32-C3 NeoPixel Slave"); // WiFi 必须先初始化才能读 MAC WiFi.mode(WIFI_AP); delay(100); generateDeviceIdentity(); Serial.println("----------------------------------------"); Serial.printf(" Device ID : %s\n", deviceID.c_str()); Serial.printf(" WiFi SSID : %s\n", apSSID.c_str()); Serial.printf(" WiFi Pass : %s\n", apPassword.c_str()); Serial.printf(" IP Address: %s\n", apIP_str.c_str()); Serial.println("----------------------------------------"); loadConfig(); // NeoPixel strip.begin(); strip.setBrightness(80); strip.clear(); strip.show(); // 开机动画 for (int round = 0; round < 2; round++) { for (int i = 0; i < NUM_PIXELS; i++) { strip.clear(); if (round == 0) { strip.setPixelColor(i, strip.Color(paperCfg.r, paperCfg.g, paperCfg.b)); } else { strip.setPixelColor(NUM_PIXELS - 1 - i, strip.Color(stoneCfg.r, stoneCfg.g, stoneCfg.b)); } strip.show(); delay(60); } } strip.clear(); strip.show(); delay(200); // I2C Slave Wire.begin(I2C_SLAVE_ADDR); Wire.onReceive(onReceive); // WiFi AP IPAddress apIP(192, 168, 4, 1); WiFi.softAPConfig(apIP, apIP, IPAddress(255, 255, 255, 0)); WiFi.softAP(apSSID.c_str(), apPassword.c_str()); delay(300); Serial.print("AP started, IP: "); Serial.println(WiFi.softAPIP()); // DNS dnsServer.start(53, "*", apIP); // 缓存页面 cachedPage = buildPageHTML(); // Web 路由 server.on("/", HTTP_GET, handleRoot); server.on("/api/info", HTTP_GET, handleGetInfo); server.on("/api/config", HTTP_GET, handleGetConfig); server.on("/api/config", HTTP_POST, handlePostConfig); server.on("/api/save", HTTP_POST, handleSave); server.on("/api/reset", HTTP_POST, handleReset); server.on("/api/test", HTTP_POST, handleTest); // Captive Portal server.on("/generate_204", HTTP_GET, handleCaptivePortal); server.on("/gen_204", HTTP_GET, handleCaptivePortal); server.on("/hotspot-detect.html", HTTP_GET, handleCaptivePortal); server.on("/library/test/success.html", HTTP_GET, handleCaptivePortal); server.on("/ncsi.txt", HTTP_GET, handleCaptivePortal); server.on("/connecttest.txt", HTTP_GET, handleCaptivePortal); server.on("/fwlink", HTTP_GET, handleCaptivePortal); server.on("/redirect", HTTP_GET, handleCaptivePortal); server.on("/canonical.html", HTTP_GET, handleCaptivePortal); server.on("/success.txt", HTTP_GET, handleCaptivePortal); server.onNotFound(handleNotFound); server.begin(); Serial.println("Web server started!"); Serial.printf("Connect to WiFi '%s' pw '%s'\n", apSSID.c_str(), apPassword.c_str()); Serial.println("Then open http://192.168.4.1"); Serial.println("Ready! Waiting for hand gesture..."); } // ==================== Loop ==================== int8_t prevTarget = -1; void loop() { dnsServer.processNextRequest(); server.handleClient(); if (millis() - lastMsgTime > IDLE_TIMEOUT) { if (currentTarget != -1) { Serial.println(">> No detection, IDLE"); currentTarget = -1; prevTarget = -1; } } if (newData) { newData = false; if (currentTarget == TARGET_PAPER) { Serial.printf("PAPER score=%d%%\n", currentScore); } else if (currentTarget == TARGET_STONE) { Serial.printf("STONE score=%d%%\n", currentScore); } else { Serial.printf("?? target=%d score=%d%%\n", currentTarget, currentScore); } if (currentTarget != prevTarget && currentTarget >= 0) { startTransition(currentTarget); prevTarget = currentTarget; } } if (effectTransition()) { delay(25); return; } if (currentTarget == TARGET_PAPER) { runPattern(paperCfg, currentScore); delay(30); } else if (currentTarget == TARGET_STONE) { runPattern(stoneCfg, currentScore); delay(20); } else { effectIdle(); delay(40); } }