/* Smart Stillness Feedback Cushion - Dual FSR + Warm Light + Web UI Bridge Board: Seeed Studio XIAO ESP32-C3 Wiring from the soldered carrier board: - Left FSR signal -> A0 - Right FSR signal -> A1 - LED / MOS PWM -> D2, XIAO ESP32-C3 GPIO4 Board AP no-USB Web UI: 1. Upload this sketch. 2. Connect the computer Wi-Fi to SmartStillness-FSR. 3. Open file:///E:/2026/FAB/2026/final/ard/fsr-simple-console.html?host=192.168.4.1 4. USB COM remains a debug fallback at http://127.0.0.1:8090/fsr-simple-console.html?usb=1. 5. Optional STA mode: set USE_STA_WIFI true and fill STA_SSID / STA_PASS locally. Do not publish real Wi-Fi passwords in Fab documentation. The sketch exposes: - Board AP WebSocket live state on ws://192.168.4.1:81/ - Board AP HTTP state on http://192.168.4.1/api/state - Serial JSON state and commands at 115200 for USB debug fallback - HTTP config on http://192.168.4.1/api/config - HTTP calibration on http://192.168.4.1/api/calibrate Serial Monitor: - Baud rate: 115200 - JSON state is printed every 200 ms for backup debugging. - Useful commands: C I R L T AUTO BREATH P:120 G:12 N:85 M:180 S:45 AT:3000 FT:4000 W:2500 BI:4000 BO:6000 D:45 */ #if !defined(ESP32) #error "This sketch is for XIAO ESP32-C3 / ESP32 boards." #endif #include #include #include #include #include // ---------------- Pins ---------------- const int LEFT_FSR_PIN = A0; const int RIGHT_FSR_PIN = A1; // XIAO ESP32-C3 board package maps D2 to GPIO4. Use GPIO4 directly as fallback. #if defined(D2) const int LED_PWM_PIN = D2; #else const int LED_PWM_PIN = 4; #endif // ---------------- Wi-Fi / Web ---------------- const char* AP_SSID = "SmartStillness-FSR"; // Optional: connect the XIAO to an existing Wi-Fi router/hotspot. // Keep this false for the stable offline demo AP mode. // If you enable it, fill these locally and do not publish the password. const bool USE_STA_WIFI = false; const char* STA_SSID = "YOUR_WIFI_NAME"; const char* STA_PASS = "YOUR_WIFI_PASSWORD"; WebServer server(80); WebSocketsServer webSocket = WebSocketsServer(81); // ---------------- Sensor model ---------------- struct SensorState { int pin; int raw; float filtered; float baseline; float pressure; }; SensorState leftSensor = { LEFT_FSR_PIN, 4095, 4095.0, 4095.0, 0.0 }; SensorState rightSensor = { RIGHT_FSR_PIN, 4095, 4095.0, 4095.0, 0.0 }; float totalPressure = 0.0; float averagePressure = 0.0; float balance = 0.0; float balanceAbs = 0.0; float balanceNormalized = 0.0; String sittingState = "idle"; // The previous MVP measured lower ADC values when pressure increased. bool pressureDropsWhenPressed = true; // ---------------- Tunable config ---------------- float pressureScale = 45.0; float smoothingAlpha = 0.08; int touchThreshold = 5; int sitThreshold = 20; int balanceThreshold = 25; unsigned long stableTime = 3000; int idleGlowPwm = 12; int minActivePwm = 85; int maxPwm = 180; unsigned long attackTime = 3000; unsigned long fadeTime = 4000; unsigned long holdBeforeBreath = 2500; unsigned long breathInMs = 4000; unsigned long breathOutMs = 6000; unsigned long breathPeriod = 10000; float breathDepth = 0.70; // Movement pauses breathing. Stillness starts it again after holdBeforeBreath. const float BREATH_MOTION_THRESHOLD = 5.0; const float BREATH_BALANCE_MOTION_WEIGHT = 0.65; const unsigned long MOTION_SAMPLE_MS = 120; const unsigned long BREATH_MOVING_LABEL_MS = 700; const unsigned long BREATH_FOLLOW_MS = 650; const float BREATH_GUIDE_TOP_FRACTION = 0.72; bool autoMode = true; bool forceBreathMode = false; bool reverseMos = false; bool ledEnabled = true; // ---------------- Runtime state ---------------- float targetPwm = 0.0; float currentPwm = 0.0; bool breathing = false; String breathStatus = "idle"; float pressureMotion = 0.0; float motionTotalReference = 0.0; float motionBalanceReference = 0.0; bool motionReferenceReady = false; unsigned long lastLoopMs = 0; unsigned long lastPublishMs = 0; unsigned long lastSerialMs = 0; unsigned long stableCandidateAt = 0; unsigned long lastMotionSampleAt = 0; unsigned long lastMovementAt = 0; unsigned long calmStartedAt = 0; unsigned long breathStartedAt = 0; unsigned long stateSeq = 0; unsigned long lastLoopDt = 0; const unsigned long PUBLISH_INTERVAL_MS = 200; const unsigned long SERIAL_INTERVAL_MS = 200; // ---------------- Utility ---------------- int clampInt(int value, int lo, int hi) { if (value < lo) return lo; if (value > hi) return hi; return value; } float clampFloat(float value, float lo, float hi) { if (value < lo) return lo; if (value > hi) return hi; return value; } float readAverage(int pin, int samples) { long sum = 0; for (int i = 0; i < samples; i += 1) { sum += analogRead(pin); delay(2); } return sum / (float)samples; } float approach(float current, float target, float maxDelta) { if (current < target) { current += maxDelta; if (current > target) current = target; } else if (current > target) { current -= maxDelta; if (current < target) current = target; } return current; } float pressureFromReading(float filtered, float baseline) { float diff = pressureDropsWhenPressed ? baseline - filtered : filtered - baseline; if (diff < 0) diff = 0; return clampFloat(diff / max(pressureScale, 1.0f), 0.0, 100.0); } void writeLedPwm(int pwm) { pwm = clampInt(pwm, 0, 255); if (!ledEnabled) pwm = 0; if (reverseMos) pwm = 255 - pwm; analogWrite(LED_PWM_PIN, pwm); } void readSensor(SensorState& sensor) { sensor.raw = analogRead(sensor.pin); sensor.filtered += smoothingAlpha * (sensor.raw - sensor.filtered); sensor.pressure = pressureFromReading(sensor.filtered, sensor.baseline); } // ---------------- State derivation ---------------- void resetStillnessTracking() { calmStartedAt = 0; lastMovementAt = 0; lastMotionSampleAt = 0; pressureMotion = 0.0; motionReferenceReady = false; breathStartedAt = 0; } void updateStillnessTracking(unsigned long now) { if (totalPressure < touchThreshold || averagePressure < sitThreshold || balanceAbs > balanceThreshold) { resetStillnessTracking(); return; } if (!motionReferenceReady) { motionReferenceReady = true; motionTotalReference = averagePressure; motionBalanceReference = balance; lastMotionSampleAt = now; calmStartedAt = now; pressureMotion = 0.0; return; } if (now - lastMotionSampleAt < MOTION_SAMPLE_MS) return; float pressureLevelMotion = fabs(averagePressure - motionTotalReference) * 0.40; float balanceMotion = fabs(balance - motionBalanceReference) * BREATH_BALANCE_MOTION_WEIGHT; pressureMotion = pressureLevelMotion > balanceMotion ? pressureLevelMotion : balanceMotion; motionTotalReference = averagePressure; motionBalanceReference = balance; lastMotionSampleAt = now; if (pressureMotion >= BREATH_MOTION_THRESHOLD) { calmStartedAt = now; lastMovementAt = now; breathStartedAt = 0; } else if (calmStartedAt == 0) { calmStartedAt = now; } } void deriveCombinedState(unsigned long now) { float left = clampFloat(leftSensor.pressure, 0.0, 100.0); float right = clampFloat(rightSensor.pressure, 0.0, 100.0); totalPressure = clampFloat(left + right, 0.0, 100.0); averagePressure = (left + right) * 0.5; // Signed pressure difference: left-heavy is negative, right-heavy is positive. balance = right - left; balanceAbs = fabs(balance); balanceNormalized = averagePressure > 0.1 ? clampFloat(balance / averagePressure, -1.0, 1.0) : 0.0; bool balancedEnough = totalPressure >= touchThreshold && averagePressure >= sitThreshold && balanceAbs <= balanceThreshold; if (balancedEnough) { if (stableCandidateAt == 0) stableCandidateAt = now; } else { stableCandidateAt = 0; } String nextState = "idle"; if (totalPressure < touchThreshold) { nextState = "idle"; } else if (averagePressure < sitThreshold) { nextState = "touch"; } else if (balanceAbs > balanceThreshold) { nextState = "leaning"; } else if (stableCandidateAt > 0 && now - stableCandidateAt >= stableTime) { nextState = "stable_sitting"; } else { nextState = "touch"; } if (sittingState != nextState) { sittingState = nextState; } updateStillnessTracking(now); } float humanBreathWave(unsigned long now) { unsigned long inMs = max(breathInMs, 500UL); unsigned long outMs = max(breathOutMs, 500UL); unsigned long totalMs = inMs + outMs; breathPeriod = totalMs; unsigned long waveNow = (breathStartedAt > 0 && now >= breathStartedAt) ? now - breathStartedAt : now; unsigned long phase = waveNow % totalMs; if (phase < inMs) { float t = phase / (float)inMs; return 0.5 - 0.5 * cos(t * PI); } float t = (phase - inMs) / (float)outMs; return 0.5 + 0.5 * cos(t * PI); } void deriveLightState(unsigned long now, unsigned long dt) { bool pressureActive = totalPressure >= touchThreshold; bool balanceReady = averagePressure >= sitThreshold && balanceAbs <= balanceThreshold; bool calmEnough = pressureActive && balanceReady && calmStartedAt > 0 && now - calmStartedAt >= holdBeforeBreath; bool shouldBreathe = forceBreathMode || calmEnough; if (shouldBreathe && !breathing) breathStartedAt = now; if (!shouldBreathe) breathStartedAt = 0; breathing = shouldBreathe; if (forceBreathMode) { breathStatus = "force"; } else if (!pressureActive) { breathStatus = "idle"; } else if (breathing) { breathStatus = "breathing"; } else if (lastMovementAt > 0 && now - lastMovementAt <= BREATH_MOVING_LABEL_MS) { breathStatus = "paused_movement"; } else { breathStatus = "waiting"; } float pressureLevel = clampFloat(averagePressure / 100.0, 0.0, 1.0); if (forceBreathMode) { int basePwm = clampInt(maxPwm, minActivePwm, 255); float wave = humanBreathWave(now); float depth = clampFloat(breathDepth, 0.0, 1.0); int lowPwm = idleGlowPwm; int highPwm = lowPwm + (int)((basePwm - lowPwm) * depth); highPwm = clampInt(highPwm, lowPwm, maxPwm); targetPwm = lowPwm + (highPwm - lowPwm) * wave; } else if (sittingState == "idle") { targetPwm = idleGlowPwm; } else { float sceneFactor = 1.0; if (sittingState == "touch") sceneFactor = 0.90; if (sittingState == "leaning") sceneFactor = 0.95; if (sittingState == "stable_sitting") sceneFactor = 1.00; int basePwm = minActivePwm + (int)((maxPwm - minActivePwm) * pressureLevel * sceneFactor); basePwm = clampInt(basePwm, minActivePwm, maxPwm); if (breathing) { int guideTopPwm = minActivePwm + (int)((maxPwm - minActivePwm) * BREATH_GUIDE_TOP_FRACTION); if (basePwm < guideTopPwm) basePwm = guideTopPwm; basePwm = clampInt(basePwm, minActivePwm, maxPwm); float wave = humanBreathWave(now); float depth = clampFloat(breathDepth, 0.0, 1.0); int lowPwm = idleGlowPwm; int highPwm = lowPwm + (int)((basePwm - lowPwm) * depth); highPwm = clampInt(highPwm, lowPwm, maxPwm); targetPwm = lowPwm + (highPwm - lowPwm) * wave; } else { targetPwm = basePwm; } } targetPwm = clampFloat(targetPwm, 0.0, 255.0); if (autoMode) { float rampTime = breathing ? (float)BREATH_FOLLOW_MS : (targetPwm > currentPwm ? attackTime : fadeTime); float maxDelta = 255.0 * ((float)dt / max(rampTime, 1.0f)); if (maxDelta < 0.12) maxDelta = 0.12; currentPwm = approach(currentPwm, targetPwm, maxDelta); currentPwm = clampFloat(currentPwm, 0.0, 255.0); writeLedPwm((int)round(currentPwm)); } } // ---------------- Calibration ---------------- void calibrateSensors() { Serial.println("# Calibrating both FSR baselines. Keep A0/A1 unloaded."); float leftSum = 0; float rightSum = 0; const int samples = 80; for (int i = 0; i < samples; i += 1) { leftSum += readAverage(LEFT_FSR_PIN, 3); rightSum += readAverage(RIGHT_FSR_PIN, 3); delay(4); } leftSensor.baseline = leftSum / samples; rightSensor.baseline = rightSum / samples; leftSensor.filtered = leftSensor.baseline; rightSensor.filtered = rightSensor.baseline; leftSensor.pressure = 0; rightSensor.pressure = 0; stableCandidateAt = 0; resetStillnessTracking(); sittingState = "idle"; targetPwm = idleGlowPwm; currentPwm = targetPwm; breathing = false; writeLedPwm((int)round(currentPwm)); Serial.print("# baseline_left="); Serial.print(leftSensor.baseline, 1); Serial.print(" baseline_right="); Serial.println(rightSensor.baseline, 1); } // ---------------- JSON protocol ---------------- String buildStateJson(bool includeConnected) { StaticJsonDocument<1536> doc; stateSeq += 1; unsigned long now = millis(); if (includeConnected) doc["connected"] = true; doc["seq"] = (uint32_t)stateSeq; doc["ms"] = (uint32_t)now; doc["loopDt"] = (uint32_t)lastLoopDt; JsonObject sensors = doc["sensors"].to(); JsonObject left = sensors["left"].to(); left["raw"] = leftSensor.raw; left["filtered"] = round(leftSensor.filtered * 10.0) / 10.0; left["baseline"] = round(leftSensor.baseline * 10.0) / 10.0; left["pressure"] = round(leftSensor.pressure * 10.0) / 10.0; JsonObject right = sensors["right"].to(); right["raw"] = rightSensor.raw; right["filtered"] = round(rightSensor.filtered * 10.0) / 10.0; right["baseline"] = round(rightSensor.baseline * 10.0) / 10.0; right["pressure"] = round(rightSensor.pressure * 10.0) / 10.0; JsonObject combined = doc["combined"].to(); combined["totalPressure"] = round(totalPressure * 10.0) / 10.0; combined["averagePressure"] = round(averagePressure * 10.0) / 10.0; combined["balance"] = round(balance * 10.0) / 10.0; combined["balanceAbs"] = round(balanceAbs * 10.0) / 10.0; combined["balanceNormalized"] = round(balanceNormalized * 100.0) / 100.0; combined["sittingState"] = sittingState; JsonObject light = doc["light"].to(); light["targetPwm"] = (int)round(targetPwm); light["finalPwm"] = (int)round(currentPwm); light["breathing"] = breathing; light["mode"] = forceBreathMode ? "breath" : (autoMode ? "auto" : "manual"); light["breathStatus"] = breathStatus; light["pressureMotion"] = round(pressureMotion * 10.0) / 10.0; light["calmMs"] = calmStartedAt > 0 ? (int)(now - calmStartedAt) : 0; JsonObject config = doc["config"].to(); config["pressureScale"] = round(pressureScale * 10.0) / 10.0; config["smoothingAlpha"] = round(smoothingAlpha * 100.0) / 100.0; config["touchThreshold"] = touchThreshold; config["sitThreshold"] = sitThreshold; config["balanceThreshold"] = balanceThreshold; config["stableTime"] = stableTime; config["idleGlowPwm"] = idleGlowPwm; config["minActivePwm"] = minActivePwm; config["maxPwm"] = maxPwm; breathPeriod = breathInMs + breathOutMs; config["breathDepth"] = (int)round(breathDepth * 100.0); config["breathInMs"] = breathInMs; config["breathOutMs"] = breathOutMs; config["breathPeriod"] = breathPeriod; config["attackTime"] = attackTime; config["fadeTime"] = fadeTime; config["holdBeforeBreath"] = holdBeforeBreath; config["pressureDropsWhenPressed"] = pressureDropsWhenPressed; config["reverseMos"] = reverseMos; config["ledEnabled"] = ledEnabled; config["forceBreathMode"] = forceBreathMode; String out; serializeJson(doc, out); return out; } void publishState(bool forceSerial) { String json = buildStateJson(true); webSocket.broadcastTXT(json); if (forceSerial || millis() - lastSerialMs >= SERIAL_INTERVAL_MS) { lastSerialMs = millis(); Serial.println(json); } } // ---------------- Config input ---------------- void applyConfigValue(const char* key, float value) { String k = String(key); if (k == "pressure_scale" || k == "pressureScale") pressureScale = clampFloat(value, 5.0, 200.0); else if (k == "smoothing_alpha" || k == "smoothingAlpha") smoothingAlpha = clampFloat(value, 0.02, 0.4); else if (k == "touch_threshold" || k == "touch") touchThreshold = clampInt((int)round(value), 0, 80); else if (k == "sit_threshold" || k == "sit") sitThreshold = clampInt((int)round(value), 1, 100); else if (k == "balance_threshold" || k == "balance") balanceThreshold = clampInt((int)round(value), 0, 100); else if (k == "stable_time" || k == "stable") stableTime = clampInt((int)round(value), 0, 12000); else if (k == "idle_glow" || k == "idle") idleGlowPwm = clampInt((int)round(value), 0, 255); else if (k == "min_active" || k == "minActivePwm" || k == "min") minActivePwm = clampInt((int)round(value), 0, 255); else if (k == "max_brightness" || k == "max") maxPwm = clampInt((int)round(value), 0, 255); else if (k == "fade_in" || k == "attack") attackTime = clampInt((int)round(value), 100, 15000); else if (k == "fade_out" || k == "fade") fadeTime = clampInt((int)round(value), 100, 15000); else if (k == "wait_before_breath" || k == "wait") holdBeforeBreath = clampInt((int)round(value), 0, 15000); else if (k == "breath_in_ms" || k == "breathInMs" || k == "inhale") breathInMs = clampInt((int)round(value), 500, 15000); else if (k == "breath_out_ms" || k == "breathOutMs" || k == "exhale") breathOutMs = clampInt((int)round(value), 500, 15000); else if (k == "breath_period" || k == "period") { unsigned long nextPeriod = clampInt((int)round(value), 1000, 27000); float ratio = breathInMs / (float)max(breathInMs + breathOutMs, 1UL); breathInMs = clampInt((int)round(nextPeriod * ratio), 500, 15000); breathOutMs = clampInt((int)(nextPeriod - breathInMs), 500, 15000); } else if (k == "breath_depth" || k == "depth") breathDepth = clampFloat(value / 100.0, 0.0, 1.0); breathPeriod = breathInMs + breathOutMs; if (maxPwm < idleGlowPwm) maxPwm = idleGlowPwm; if (maxPwm < minActivePwm) maxPwm = minActivePwm; if (minActivePwm < idleGlowPwm) minActivePwm = idleGlowPwm; if (sitThreshold < touchThreshold) sitThreshold = touchThreshold; } void runLedTest() { bool wasAuto = autoMode; autoMode = false; ledEnabled = true; Serial.println("# LED_TEST full -> off -> medium -> off"); writeLedPwm(255); delay(800); writeLedPwm(0); delay(400); writeLedPwm(120); delay(800); writeLedPwm(0); delay(400); autoMode = wasAuto; } void applyJsonConfig(const char* text) { StaticJsonDocument<512> doc; DeserializationError error = deserializeJson(doc, text); if (error) return; if (doc["calibrate"] == true) { calibrateSensors(); publishState(true); return; } for (JsonPair kv : doc.as()) { if (kv.value().is() || kv.value().is()) { applyConfigValue(kv.key().c_str(), kv.value().as()); } } publishState(true); } void handleTextCommand(String cmd) { cmd.trim(); if (cmd.length() == 0) return; if (cmd.startsWith("{")) { applyJsonConfig(cmd.c_str()); return; } if (cmd == "C") { calibrateSensors(); } else if (cmd == "I") { pressureDropsWhenPressed = !pressureDropsWhenPressed; } else if (cmd == "L") { ledEnabled = !ledEnabled; } else if (cmd == "T") { runLedTest(); } else if (cmd == "AUTO") { forceBreathMode = false; autoMode = true; } else if (cmd == "BREATH") { ledEnabled = true; autoMode = true; forceBreathMode = true; } else if (cmd == "R") { reverseMos = !reverseMos; } else if (cmd.startsWith("P:")) { forceBreathMode = false; autoMode = false; currentPwm = clampInt(cmd.substring(2).toInt(), 0, 255); writeLedPwm((int)currentPwm); } else if (cmd.startsWith("G:")) { applyConfigValue("idle", cmd.substring(2).toFloat()); } else if (cmd.startsWith("N:")) { applyConfigValue("min", cmd.substring(2).toFloat()); } else if (cmd.startsWith("M:")) { applyConfigValue("max", cmd.substring(2).toFloat()); } else if (cmd.startsWith("S:")) { applyConfigValue("pressure_scale", cmd.substring(2).toFloat()); } else if (cmd.startsWith("AT:")) { applyConfigValue("attack", cmd.substring(3).toFloat()); } else if (cmd.startsWith("FT:")) { applyConfigValue("fade", cmd.substring(3).toFloat()); } else if (cmd.startsWith("W:")) { applyConfigValue("wait", cmd.substring(2).toFloat()); } else if (cmd.startsWith("BI:")) { applyConfigValue("breath_in_ms", cmd.substring(3).toFloat()); } else if (cmd.startsWith("BO:")) { applyConfigValue("breath_out_ms", cmd.substring(3).toFloat()); } else if (cmd.startsWith("B:")) { applyConfigValue("period", cmd.substring(2).toFloat()); } else if (cmd.startsWith("D:")) { applyConfigValue("depth", cmd.substring(2).toFloat()); } publishState(true); } void handleSerialCommand() { static String input = ""; static unsigned long lastCharAt = 0; while (Serial.available()) { char c = (char)Serial.read(); if (c == '\n' || c == '\r') { if (input.length() > 0) handleTextCommand(input); input = ""; } else { input += c; lastCharAt = millis(); if (input.length() > 512) input = ""; } } if (input.length() > 0 && millis() - lastCharAt > 250) { handleTextCommand(input); input = ""; } } // ---------------- Web handlers ---------------- void setCorsHeaders() { server.sendHeader("Access-Control-Allow-Origin", "*"); server.sendHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); server.sendHeader("Access-Control-Allow-Headers", "Content-Type"); } void handleStateHttp() { setCorsHeaders(); server.send(200, "application/json", buildStateJson(true)); } void handleConfigHttp() { setCorsHeaders(); for (int i = 0; i < server.args(); i += 1) { applyConfigValue(server.argName(i).c_str(), server.arg(i).toFloat()); } server.send(200, "application/json", buildStateJson(true)); publishState(true); } void handleCalibrateHttp() { calibrateSensors(); setCorsHeaders(); server.send(200, "application/json", buildStateJson(true)); publishState(true); } void handleCommandHttp() { setCorsHeaders(); String cmd = server.hasArg("cmd") ? server.arg("cmd") : ""; handleTextCommand(cmd); server.send(200, "application/json", buildStateJson(true)); publishState(true); } void handleOptionsHttp() { setCorsHeaders(); server.send(204); } void handleRootHttp() { setCorsHeaders(); String html = "" "" "Smart Stillness FSR Bridge" "

Smart Stillness FSR Bridge

" "

Open the existing Week15 page with:

" "file:///E:/2026/FAB/2026/week15/week15-esp32-interface/web-preview/index.html?host=192.168.4.1" "

/api/state

" "

/api/calibrate resets FSR baselines while unloaded.

" ""; server.send(200, "text/html", html); } void webSocketEvent(const uint8_t& num, const WStype_t& type, uint8_t* payload, const size_t& length) { switch (type) { case WStype_CONNECTED: webSocket.sendTXT(num, buildStateJson(true)); break; case WStype_TEXT: if (payload && length > 0) { String msg; for (size_t i = 0; i < length; i += 1) { msg += (char)payload[i]; } handleTextCommand(msg); } break; default: break; } } void setupWeb() { WiFi.mode(USE_STA_WIFI ? WIFI_AP_STA : WIFI_AP); bool staOk = false; if (USE_STA_WIFI) { Serial.print("# Connecting STA Wi-Fi: "); Serial.println(STA_SSID); WiFi.begin(STA_SSID, STA_PASS); unsigned long startedAt = millis(); while (WiFi.status() != WL_CONNECTED && millis() - startedAt < 10000) { delay(250); Serial.print("."); } Serial.println(); staOk = WiFi.status() == WL_CONNECTED; Serial.print("# STA status: "); Serial.println(staOk ? "ok" : "failed"); if (staOk) { Serial.print("# STA IP: "); Serial.println(WiFi.localIP()); } } bool apOk = WiFi.softAP(AP_SSID); delay(250); Serial.println(); Serial.print("# WiFi AP: "); Serial.println(AP_SSID); Serial.print("# AP status: "); Serial.println(apOk ? "ok" : "failed"); Serial.print("# AP IP: "); Serial.println(WiFi.softAPIP()); Serial.println("# AP page host: 192.168.4.1"); if (staOk) { Serial.print("# STA page host: "); Serial.println(WiFi.localIP()); } server.on("/", HTTP_GET, handleRootHttp); server.on("/api/state", HTTP_GET, handleStateHttp); server.on("/api/config", HTTP_GET, handleConfigHttp); server.on("/api/calibrate", HTTP_GET, handleCalibrateHttp); server.on("/api/command", HTTP_GET, handleCommandHttp); server.onNotFound([]() { if (server.method() == HTTP_OPTIONS) { handleOptionsHttp(); return; } setCorsHeaders(); server.send(404, "text/plain", "not found"); }); server.begin(); webSocket.begin(); webSocket.onEvent(webSocketEvent); } // ---------------- Setup / Loop ---------------- void setup() { Serial.begin(115200); delay(1200); pinMode(LEFT_FSR_PIN, INPUT); pinMode(RIGHT_FSR_PIN, INPUT); pinMode(LED_PWM_PIN, OUTPUT); analogReadResolution(12); writeLedPwm(0); Serial.println(); Serial.println("# Smart Stillness Dual FSR + LED + Week15 Web Bridge"); Serial.print("# Left FSR A0, Right FSR A1, LED PWM pin="); Serial.println(LED_PWM_PIN); Serial.println("# Commands: C I R L T AUTO BREATH P:120 G:12 N:85 M:180 S:45 AT:3000 FT:4000 W:2500 BI:4000 BO:6000 D:45"); setupWeb(); calibrateSensors(); lastLoopMs = millis(); } void loop() { server.handleClient(); webSocket.loop(); handleSerialCommand(); unsigned long now = millis(); unsigned long dt = now - lastLoopMs; lastLoopDt = dt; lastLoopMs = now; readSensor(leftSensor); readSensor(rightSensor); deriveCombinedState(now); deriveLightState(now, dt); if (now - lastPublishMs >= PUBLISH_INTERVAL_MS) { lastPublishMs = now; publishState(false); } }