diff --git a/.gitignore b/.gitignore index a5d169d..11ac6c9 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ pid_log.csv *.mp4 *.avi python_host/data/ +python_host/models/vit-emotion/model.safetensors # Arduino *.hex diff --git a/esp32_firmware/esp32_kait/kait_v2.ino b/esp32_firmware/esp32_kait/kait_v2.ino index 3995668..f4b336a 100644 --- a/esp32_firmware/esp32_kait/kait_v2.ino +++ b/esp32_firmware/esp32_kait/kait_v2.ino @@ -17,6 +17,12 @@ const char* MDNS_NAME = "F7OWER_kait"; // --- OSC 端口 --- const int OSC_PORT = 8888; +const int WIFI_BOOT_CONNECT_ATTEMPTS = 24; +const int WIFI_AUTO_RETRY_ATTEMPTS = 10; +const int WIFI_MANUAL_RETRY_DEFAULT = 6; +const int WIFI_RETRY_DELAY_MS = 500; +const unsigned long WIFI_RETRY_INTERVAL_MS = 6000; + // --- 引脚定义 --- const int MOTOR_PWM_PIN = 22; // PWM 速度控制 const int MOTOR_DIR_PIN = 23; // 方向控制 @@ -33,6 +39,9 @@ const int MOTOR_KICK_START_DELAY = 30; // 启动冲击延时 (ms) // 运行时变量 // ============================================================ WiFiUDP udp; +unsigned long lastWifiRetryMs = 0; +int wifiManualRetryAttempts = WIFI_MANUAL_RETRY_DEFAULT; +bool mdnsStarted = false; // Motor state / 电机状态 struct MotorState { @@ -69,38 +78,90 @@ void handleSerialCommand(); // ──────────────────────────────────────────────────────────── // ============================================================ -// WiFi 初始化(Station 模式只) +// WiFi / mDNS // ============================================================ -void setupWiFi() { +bool connectWifiWithAttempts(int attempts, bool verbose) { + attempts = constrain(attempts, 1, 120); WiFi.mode(WIFI_STA); + WiFi.setSleep(false); + WiFi.setAutoReconnect(true); + WiFi.persistent(false); WiFi.begin(STA_SSID, STA_PASSWORD); - Serial.print("🔗 连接WiFi中"); - int retry = 0; - while (WiFi.status() != WL_CONNECTED && retry < 20) { - delay(500); - Serial.print("."); - retry++; + if (verbose) Serial.print("[Net] Connecting"); + for (int i = 0; i < attempts; i++) { + if (WiFi.status() == WL_CONNECTED) { + if (verbose) { + Serial.print("\n[Net] Connected, IP: "); + Serial.println(WiFi.localIP()); + } + return true; + } + delay(WIFI_RETRY_DELAY_MS); + if (verbose) Serial.print("."); } - if (WiFi.status() == WL_CONNECTED) { - Serial.print("\n✅ WiFi已连接,IP: "); - Serial.println(WiFi.localIP()); - } else { - Serial.println("\n❌ WiFi连接失败,请检查 STA_SSID / STA_PASSWORD"); + if (verbose) { + Serial.println("\n[Net] STA connect failed"); } + return WiFi.status() == WL_CONNECTED; } -// ============================================================ -// mDNS 初始化 -// ============================================================ -void setupmDNS() { - if (MDNS.begin(MDNS_NAME)) { - Serial.printf("✅ mDNS 已启动: http://%s.local\n", MDNS_NAME); - MDNS.addService("osc", "udp", OSC_PORT); +void setupNetwork() { + if (!connectWifiWithAttempts(WIFI_BOOT_CONNECT_ATTEMPTS, true)) { + Serial.println("[Net] Boot without WiFi, auto-retry enabled"); + } +} + +void ensureWifiConnected() { + if (WiFi.status() == WL_CONNECTED) return; + unsigned long now = millis(); + if (now - lastWifiRetryMs < WIFI_RETRY_INTERVAL_MS) return; + lastWifiRetryMs = now; + Serial.println("[Net] WiFi disconnected, retrying..."); + connectWifiWithAttempts(WIFI_AUTO_RETRY_ATTEMPTS, false); +} + +void setupMDNS() { + if (mdnsStarted) return; + if (WiFi.status() != WL_CONNECTED) return; + if (!MDNS.begin(MDNS_NAME)) { + Serial.println("[Net] mDNS failed"); + return; + } + MDNS.addService("osc", "udp", OSC_PORT); + MDNS.addService("datt_flower", "tcp", OSC_PORT); + MDNS.addServiceTxt("datt_flower", "tcp", "node_type", "kait"); + MDNS.addServiceTxt("datt_flower", "tcp", "node_id", MDNS_NAME); + mdnsStarted = true; + Serial.printf("[Net] mDNS ready: %s.local\n", MDNS_NAME); +} + +void ensureMDNS() { + if (!mdnsStarted && WiFi.status() == WL_CONNECTED) { + setupMDNS(); + } +} + +void printWifiStatus() { + wl_status_t st = WiFi.status(); + Serial.println("\n=== WiFi Status ==="); + Serial.printf("SSID: %s\n", STA_SSID); + Serial.printf("Status: %d\n", (int)st); + if (st == WL_CONNECTED) { + Serial.printf("IP: %d.%d.%d.%d\n", WiFi.localIP()[0], WiFi.localIP()[1], WiFi.localIP()[2], WiFi.localIP()[3]); + Serial.printf("RSSI: %d dBm\n", WiFi.RSSI()); } else { - Serial.println("❌ mDNS 启动失败"); + Serial.println("IP: (not connected)"); } + Serial.println("===================\n"); +} + +void manualWifiRetry(int attempts) { + wifiManualRetryAttempts = constrain(attempts, 1, 120); + Serial.printf("[Net] Manual retry, attempts=%d\n", wifiManualRetryAttempts); + bool ok = connectWifiWithAttempts(wifiManualRetryAttempts, true); + if (ok) ensureMDNS(); } // ============================================================ @@ -331,6 +392,8 @@ void handleSerialCommand() { Serial.println("\n=== 串口命令帮助 ==="); Serial.println("motor - 设置电机速度 (-255 ~ 255)"); Serial.println("motion - 执行运动模式 (1-6)"); + Serial.println("wifi status - 查看 WiFi 状态"); + Serial.println("wifi retry - 手动重试 WiFi 连接"); Serial.println("stop - 停止电机"); Serial.println("info - 显示设备信息"); Serial.println("help - 显示此帮助"); @@ -349,6 +412,12 @@ void handleSerialCommand() { motorState.isRunning ? "运行中" : "停止", motorState.currentSpeed); Serial.println("====================\n"); + } else if (line.equals("wifi status")) { + printWifiStatus(); + } else if (line.startsWith("wifi retry")) { + int attempts = wifiManualRetryAttempts; + sscanf(line.c_str(), "wifi retry %d", &attempts); + manualWifiRetry(attempts); } } @@ -368,8 +437,8 @@ void setup() { Serial.println("\n========== F7OWER Kait Node v2 =========="); Serial.println("设置 WiFi 连接..."); - setupWiFi(); - setupmDNS(); + setupNetwork(); + setupMDNS(); udp.begin(OSC_PORT); Serial.printf("✅ OSC 监听端口: %d\n", OSC_PORT); @@ -400,6 +469,9 @@ void loop() { // 串口命令处理 handleSerialCommand(); + ensureWifiConnected(); + ensureMDNS(); + // 自动序列(如果激活) runAutoSequence(); } diff --git a/esp32_firmware/esp32_sue/esp32_sue.ino b/esp32_firmware/esp32_sue/esp32_sue.ino new file mode 100644 index 0000000..c84ac07 --- /dev/null +++ b/esp32_firmware/esp32_sue/esp32_sue.ino @@ -0,0 +1,126 @@ +#include +#include + +// DISPLAY +#define TFT_MOSI 23 +#define TFT_SCLK 18 +#define TFT_CS 19 +#define TFT_DC 21 +#define TFT_RST 22 + +Arduino_DataBus *bus = new Arduino_ESP32SPI(TFT_DC, TFT_CS, TFT_SCLK, TFT_MOSI, -1); +Arduino_GFX *panel = new Arduino_GC9A01(bus, TFT_RST, 0); + +// Local canvas covering only the pupil + iris region +// Iris radius = 30, pupil radius = 13, pupil travels x = 110~130 +// Bounding box with margin: x=90~150 (w=60), y=84~156 (h=72) +#define CANVAS_X 90 +#define CANVAS_Y 84 +#define CANVAS_W 60 +#define CANVAS_H 72 + +Arduino_GFX *sprite = new Arduino_Canvas(CANVAS_W, CANVAS_H, panel, CANVAS_X, CANVAS_Y); + +// Local-space center (offset from canvas origin) +#define LCX (120 - CANVAS_X) // 30 +#define LCY (120 - CANVAS_Y) // 36 + +// SERVO +Servo servo1; +#define SERVO1_PIN 4 + +// COLORS +#define BLACK 0x0000 +#define WHITE 0xFFFF +#define SCLERA 0xEF7D +#define IRIS_DARK 0x0015 +#define IRIS_MID 0x027F +#define IRIS_LIGHT 0x3DFF +#define SHADOW 0x4208 + +#define CX 120 +#define CY 120 + + +// Draw full static eye directly to panel — called ONCE in setup() +void drawStaticEye() +{ + panel->fillScreen(BLACK); + // eyeball + panel->fillCircle(CX, CY, 65, SCLERA); + // iris + panel->fillCircle(CX, CY, 30, IRIS_DARK); + panel->fillCircle(CX, CY, 24, IRIS_MID); + panel->fillCircle(CX, CY, 16, IRIS_LIGHT); + + panel->fillRoundRect(35, 40, 170, 20, 10, SHADOW); +} + + +// Redraw pupil region into sprite buffer, flush patch to screen +void drawPupil(int pupilX) +{ + int lx = pupilX - CANVAS_X; // pupil x in local canvas space + + // clear sprite and redraw iris layers that fall inside this region + sprite->fillScreen(BLACK); + sprite->fillCircle(LCX, LCY, 65, SCLERA); + sprite->fillCircle(LCX, LCY, 30, IRIS_DARK); + sprite->fillCircle(LCX, LCY, 24, IRIS_MID); + sprite->fillCircle(LCX, LCY, 16, IRIS_LIGHT); + + // pupil + highlight in local coords + sprite->fillCircle(lx, LCY, 13, BLACK); + sprite->fillCircle(lx - 4, LCY - 6, 4, WHITE); + + // push sprite patch to screen — only 60×72 = 4320 pixels over SPI + ((Arduino_Canvas *)sprite)->flush(); +} + + +void setup() +{ + Serial.begin(115200); + Serial.print("MaxAllocHeap: "); Serial.println(ESP.getMaxAllocHeap()); + + panel->begin(); + panel->setRotation(2); + + Serial.println("panel ok"); + + sprite->begin(); + + Serial.println("sprite ok"); + + servo1.setPeriodHertz(50); + servo1.attach(SERVO1_PIN, 500, 2400); + + drawStaticEye(); // full background drawn once to panel GRAM + drawPupil(CX); // initial pupil +} + + +void loop() +{ + // sweep 0 → 180 + for (int pos = 0; pos <= 120; pos++) + { + servo1.write(pos); + int pupilX = map(pos, 0, 180, 110, 130); + drawPupil(pupilX); + delay(2); + } + + delay(500); + + // sweep 180 → 0 + for (int pos = 100; pos >= 0; pos--) + { + servo1.write(pos); + int pupilX = map(pos, 0, 180, 110, 130); + drawPupil(pupilX); + delay(2); + } + + delay(500); +} \ No newline at end of file diff --git a/esp32_firmware_refactored/face_tracking/face_tracking.ino b/esp32_firmware_refactored/face_tracking/face_tracking.ino index 0be4b73..99747f6 100644 --- a/esp32_firmware_refactored/face_tracking/face_tracking.ino +++ b/esp32_firmware_refactored/face_tracking/face_tracking.ino @@ -3,15 +3,11 @@ #include #include #include +#include // ============================================================ -// Configuration +// Network / node configuration // ============================================================ -#define USE_AP_MODE false - -const char* AP_SSID = "F7OWER"; -const char* AP_PASSWORD = "12345678"; - const char* STA_SSID = "F7OWER"; const char* STA_PASSWORD = "12345678"; @@ -19,15 +15,35 @@ const char* NODE_ID = "face_track_1"; const char* NODE_TYPE = "face_track"; const int OSC_PORT = 8888; +const int WIFI_BOOT_CONNECT_ATTEMPTS = 24; +const int WIFI_AUTO_RETRY_ATTEMPTS = 10; +const int WIFI_MANUAL_RETRY_DEFAULT = 6; +const int WIFI_RETRY_DELAY_MS = 500; +const unsigned long WIFI_RETRY_INTERVAL_MS = 6000; + +// ============================================================ +// Servo / tracking configuration +// ============================================================ const int FRAME_WIDTH_DEFAULT = 1920; const int FRAME_HEIGHT_DEFAULT = 1080; const int SERVO_MIN_US = 500; const int SERVO_MAX_US = 2400; const int SERVO_HZ = 50; - const int SERVO_UPDATE_MS = 20; const int SERIAL_BAUD = 115200; +const int SERIAL_TIMEOUT_MS = 20; + +const int PAN_MIN_DEFAULT = 20; +const int PAN_MAX_DEFAULT = 160; +const int TILT_MIN_DEFAULT = 50; +const int TILT_MAX_DEFAULT = 180; + +const int DEAD_BAND_DEG_DEFAULT = 1; +const int SMOOTH_PCT_DEFAULT = 40; +const int MAX_STEP_DEG_DEFAULT = 4; +const unsigned long TRACK_HOLD_TIMEOUT_MS = 1200; +const float MODE_BLEND_STEP = 0.08f; // Pan(X) and Tilt(Y) pins for 4 flowers int pinsX[4] = {18, 21, 23, 26}; @@ -36,38 +52,66 @@ int pinsY[4] = {19, 22, 25, 27}; // ============================================================ // Runtime state // ============================================================ +enum ControlMode { + MODE_MANUAL = 0, + MODE_TRACKING = 1, +}; + WiFiUDP udp; Servo servosX[4]; Servo servosY[4]; -bool autoTracking = true; -int smoothFactorPct = 40; // 0-100, larger = faster response -int deadbandDeg = 1; +ControlMode controlMode = MODE_TRACKING; +float modeBlend = 1.0f; // 1.0=tracking, 0.0=manual + +int smoothFactorPct = SMOOTH_PCT_DEFAULT; +int deadbandDeg = DEAD_BAND_DEG_DEFAULT; +int maxStepDeg = MAX_STEP_DEG_DEFAULT; -int targetPan = 90; -int targetTilt = 90; -int currentPan = 90; -int currentTilt = 90; +int panMinDeg = PAN_MIN_DEFAULT; +int panMaxDeg = PAN_MAX_DEFAULT; +int tiltMinDeg = TILT_MIN_DEFAULT; +int tiltMaxDeg = TILT_MAX_DEFAULT; +int trackingPan = 90; +int trackingTilt = 90; +int manualPan[4] = {90, 90, 90, 90}; +int manualTilt[4] = {90, 90, 90, 90}; +int targetPan[4] = {90, 90, 90, 90}; +int targetTilt[4] = {90, 90, 90, 90}; +float currentPan[4] = {90, 90, 90, 90}; +float currentTilt[4] = {90, 90, 90, 90}; + +unsigned long lastTrackInputMs = 0; unsigned long lastServoUpdateMs = 0; +unsigned long lastWifiRetryMs = 0; +int wifiManualRetryAttempts = WIFI_MANUAL_RETRY_DEFAULT; +bool mdnsStarted = false; // ============================================================ // Forward declarations // ============================================================ void setupNetwork(); +void ensureWifiConnected(); +bool connectWifiWithAttempts(int attempts, bool verbose); void setupMDNS(); +void ensureMDNS(); void setupServos(); void updateServos(); -void applyTargetAngles(int pan, int tilt); -void setAllServos(int pan, int tilt); +void updateServoTargets(); void parseSerialLine(); void printHelp(); +void printSelfInfo(); +void printWifiStatus(); +void manualWifiRetry(int attempts); void routeTrackAuto(OSCMessage& msg, int addrOffset); +void routeTrackMode(OSCMessage& msg, int addrOffset); void routeTrackNorm(OSCMessage& msg, int addrOffset); void routeTrackXY(OSCMessage& msg, int addrOffset); void routeTrackCenter(OSCMessage& msg, int addrOffset); void routeTrackSmooth(OSCMessage& msg, int addrOffset); +void routeTrackLimits(OSCMessage& msg, int addrOffset); void routeFlower1(OSCMessage& msg, int addrOffset); void routeFlower2(OSCMessage& msg, int addrOffset); void routeFlower3(OSCMessage& msg, int addrOffset); @@ -78,136 +122,266 @@ void routeInfoServo(OSCMessage& msg, int addrOffset); // ============================================================ // Utility helpers // ============================================================ -int smoothStep(int currentValue, int targetValue) { - int delta = targetValue - currentValue; - if (abs(delta) <= deadbandDeg) { +float clampf(float value, float low, float high) { + if (value < low) return low; + if (value > high) return high; + return value; +} + +int centerPanDeg() { + return constrain((panMinDeg + panMaxDeg) / 2, panMinDeg, panMaxDeg); +} + +int centerTiltDeg() { + return constrain((tiltMinDeg + tiltMaxDeg) / 2, tiltMinDeg, tiltMaxDeg); +} + +int mapNormToPan(float nx) { + nx = clampf(nx, 0.0f, 1.0f); + float span = (float)(panMaxDeg - panMinDeg); + int pan = (int)round((float)panMaxDeg - (nx * span)); + return constrain(pan, panMinDeg, panMaxDeg); +} + +int mapNormToTilt(float ny) { + ny = clampf(ny, 0.0f, 1.0f); + float span = (float)(tiltMaxDeg - tiltMinDeg); + int tilt = (int)round((float)tiltMaxDeg - (ny * span)); + return constrain(tilt, tiltMinDeg, tiltMaxDeg); +} + +void clampAllStateToLimits() { + trackingPan = constrain(trackingPan, panMinDeg, panMaxDeg); + trackingTilt = constrain(trackingTilt, tiltMinDeg, tiltMaxDeg); + for (int i = 0; i < 4; i++) { + manualPan[i] = constrain(manualPan[i], panMinDeg, panMaxDeg); + manualTilt[i] = constrain(manualTilt[i], tiltMinDeg, tiltMaxDeg); + targetPan[i] = constrain(targetPan[i], panMinDeg, panMaxDeg); + targetTilt[i] = constrain(targetTilt[i], tiltMinDeg, tiltMaxDeg); + currentPan[i] = clampf(currentPan[i], (float)panMinDeg, (float)panMaxDeg); + currentTilt[i] = clampf(currentTilt[i], (float)tiltMinDeg, (float)tiltMaxDeg); + } +} + +void setControlMode(ControlMode mode) { + if (controlMode == mode) return; + controlMode = mode; + Serial.printf("[Mode] %s\n", controlMode == MODE_TRACKING ? "TRACKING" : "MANUAL"); +} + +void setTrackByNorm(float nx, float ny) { + trackingPan = mapNormToPan(nx); + trackingTilt = mapNormToTilt(ny); + lastTrackInputMs = millis(); +} + +void setTrackByPixel(int x, int y, int frameW, int frameH) { + frameW = max(frameW, 1); + frameH = max(frameH, 1); + float nx = (float)constrain(x, 0, frameW) / (float)frameW; + float ny = (float)constrain(y, 0, frameH) / (float)frameH; + setTrackByNorm(nx, ny); +} + +void setManualFlower(int flowerIdx, int pan, int tilt, bool switchToManual) { + if (flowerIdx < 0 || flowerIdx > 3) return; + manualPan[flowerIdx] = constrain(pan, panMinDeg, panMaxDeg); + manualTilt[flowerIdx] = constrain(tilt, tiltMinDeg, tiltMaxDeg); + if (switchToManual) setControlMode(MODE_MANUAL); +} + +float smoothStep(float currentValue, float targetValue) { + float delta = targetValue - currentValue; + if (fabs(delta) <= (float)deadbandDeg) { return targetValue; } - int step = (abs(delta) * smoothFactorPct) / 100; - if (step < 1) step = 1; - if (delta > 0) return currentValue + step; - return currentValue - step; + float pct = clampf((float)smoothFactorPct / 100.0f, 0.0f, 1.0f); + float step = fabs(delta) * pct; + if (step < 0.6f) step = 0.6f; + if (step > (float)maxStepDeg) step = (float)maxStepDeg; + + if (delta > 0.0f) { + return min(currentValue + step, targetValue); + } + return max(currentValue - step, targetValue); } -void setAllServos(int pan, int tilt) { - pan = constrain(pan, 0, 180); - tilt = constrain(tilt, 0, 180); +void writeServosNow() { for (int i = 0; i < 4; i++) { - servosX[i].write(pan); - servosY[i].write(tilt); + servosX[i].write((int)round(currentPan[i])); + servosY[i].write((int)round(currentTilt[i])); } } -void applyTargetAngles(int pan, int tilt) { - targetPan = constrain(pan, 0, 180); - targetTilt = constrain(tilt, 0, 180); -} +void updateServoTargets() { + if (controlMode == MODE_TRACKING) { + modeBlend = min(1.0f, modeBlend + MODE_BLEND_STEP); + } else { + modeBlend = max(0.0f, modeBlend - MODE_BLEND_STEP); + } -void updateServos() { + int desiredTrackPan = trackingPan; + int desiredTrackTilt = trackingTilt; unsigned long now = millis(); - if (now - lastServoUpdateMs < (unsigned long)SERVO_UPDATE_MS) { - return; + if (now - lastTrackInputMs > TRACK_HOLD_TIMEOUT_MS) { + desiredTrackPan = centerPanDeg(); + desiredTrackTilt = centerTiltDeg(); } - lastServoUpdateMs = now; - currentPan = smoothStep(currentPan, targetPan); - currentTilt = smoothStep(currentTilt, targetTilt); - setAllServos(currentPan, currentTilt); + for (int i = 0; i < 4; i++) { + float mixedPan = ((float)manualPan[i] * (1.0f - modeBlend)) + ((float)desiredTrackPan * modeBlend); + float mixedTilt = ((float)manualTilt[i] * (1.0f - modeBlend)) + ((float)desiredTrackTilt * modeBlend); + targetPan[i] = constrain((int)round(mixedPan), panMinDeg, panMaxDeg); + targetTilt[i] = constrain((int)round(mixedTilt), tiltMinDeg, tiltMaxDeg); + } } -void applyNormTarget(float nx, float ny) { - nx = constrain(nx, 0.0f, 1.0f); - ny = constrain(ny, 0.0f, 1.0f); +void updateServos() { + unsigned long now = millis(); + if (now - lastServoUpdateMs < (unsigned long)SERVO_UPDATE_MS) return; + lastServoUpdateMs = now; - // Mirror left-right to match original mapping direction. - int pan = map((int)(nx * 1000.0f), 0, 1000, 180, 0); - int tilt = map((int)(ny * 1000.0f), 0, 1000, 180, 0); - applyTargetAngles(pan, tilt); + updateServoTargets(); + for (int i = 0; i < 4; i++) { + currentPan[i] = smoothStep(currentPan[i], (float)targetPan[i]); + currentTilt[i] = smoothStep(currentTilt[i], (float)targetTilt[i]); + } + writeServosNow(); } -void applyPixelTarget(int x, int y, int frameW, int frameH) { - frameW = max(frameW, 1); - frameH = max(frameH, 1); +void applyAngleLimits(int panMin, int panMax, int tiltMin, int tiltMax) { + panMinDeg = constrain(panMin, 0, 180); + panMaxDeg = constrain(panMax, 0, 180); + tiltMinDeg = constrain(tiltMin, 0, 180); + tiltMaxDeg = constrain(tiltMax, 0, 180); - int pan = map(constrain(x, 0, frameW), 0, frameW, 180, 0); - int tilt = map(constrain(y, 0, frameH), 0, frameH, 180, 0); - applyTargetAngles(pan, tilt); + if (panMinDeg > panMaxDeg) { + int t = panMinDeg; + panMinDeg = panMaxDeg; + panMaxDeg = t; + } + if (tiltMinDeg > tiltMaxDeg) { + int t = tiltMinDeg; + tiltMinDeg = tiltMaxDeg; + tiltMaxDeg = t; + } + clampAllStateToLimits(); + Serial.printf("[Servo] limits pan=%d..%d tilt=%d..%d\n", panMinDeg, panMaxDeg, tiltMinDeg, tiltMaxDeg); } -void printSelfInfo() { - uint8_t mac[6]; - WiFi.macAddress(mac); - IPAddress ip = USE_AP_MODE ? WiFi.softAPIP() : WiFi.localIP(); - - Serial.println("\n=== Face Tracking Node Info ==="); - Serial.printf("Node ID: %s\n", NODE_ID); - Serial.printf("Node Type: %s\n", NODE_TYPE); - Serial.printf("IP: %d.%d.%d.%d\n", ip[0], ip[1], ip[2], ip[3]); - Serial.printf("MAC: %02X:%02X:%02X:%02X:%02X:%02X\n", - mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); - Serial.printf("AutoTracking: %s\n", autoTracking ? "ON" : "OFF"); - Serial.printf("Current Pan/Tilt: %d / %d\n", currentPan, currentTilt); - Serial.printf("Target Pan/Tilt: %d / %d\n", targetPan, targetTilt); - Serial.printf("Smoothing: %d%%\n", smoothFactorPct); - Serial.println("===============================\n"); +void centerAllModes() { + int cp = centerPanDeg(); + int ct = centerTiltDeg(); + trackingPan = cp; + trackingTilt = ct; + for (int i = 0; i < 4; i++) { + manualPan[i] = cp; + manualTilt[i] = ct; + targetPan[i] = cp; + targetTilt[i] = ct; + } } // ============================================================ // Network setup // ============================================================ -void setupNetwork() { - if (USE_AP_MODE) { - WiFi.mode(WIFI_AP); - WiFi.softAP(AP_SSID, AP_PASSWORD); - Serial.print("[Net] AP started, IP: "); - Serial.println(WiFi.softAPIP()); - return; - } +bool connectWifiWithAttempts(int attempts, bool verbose) { + attempts = constrain(attempts, 1, 120); WiFi.mode(WIFI_STA); + WiFi.setSleep(false); + WiFi.setAutoReconnect(true); + WiFi.persistent(false); WiFi.begin(STA_SSID, STA_PASSWORD); - Serial.print("[Net] Connecting"); - int retry = 0; - while (WiFi.status() != WL_CONNECTED && retry < 20) { - delay(500); - Serial.print("."); - retry++; + if (verbose) Serial.print("[Net] Connecting"); + for (int i = 0; i < attempts; i++) { + if (WiFi.status() == WL_CONNECTED) { + if (verbose) { + Serial.print("\n[Net] Connected, IP: "); + Serial.println(WiFi.localIP()); + } + return true; + } + delay(WIFI_RETRY_DELAY_MS); + if (verbose) Serial.print("."); } - if (WiFi.status() == WL_CONNECTED) { - Serial.print("\n[Net] Connected, IP: "); - Serial.println(WiFi.localIP()); - } else { - Serial.println("\n[Net] STA failed, fallback to AP mode"); - WiFi.disconnect(true); - WiFi.mode(WIFI_AP); - WiFi.softAP(AP_SSID, AP_PASSWORD); - Serial.print("[Net] AP started, IP: "); - Serial.println(WiFi.softAPIP()); + if (verbose) { + Serial.println("\n[Net] STA connect failed"); } + return WiFi.status() == WL_CONNECTED; +} + +void setupNetwork() { + if (!connectWifiWithAttempts(WIFI_BOOT_CONNECT_ATTEMPTS, true)) { + Serial.println("[Net] Boot without WiFi, auto-retry enabled"); + } +} + +void ensureWifiConnected() { + if (WiFi.status() == WL_CONNECTED) return; + unsigned long now = millis(); + if (now - lastWifiRetryMs < WIFI_RETRY_INTERVAL_MS) return; + lastWifiRetryMs = now; + + Serial.println("[Net] WiFi disconnected, retrying..."); + connectWifiWithAttempts(WIFI_AUTO_RETRY_ATTEMPTS, false); } void setupMDNS() { + if (mdnsStarted) return; + if (WiFi.status() != WL_CONNECTED) return; + if (!MDNS.begin(NODE_ID)) { Serial.println("[Net] mDNS failed"); return; } - // For generic OSC lookup (_osc._udp) MDNS.addService("osc", "udp", OSC_PORT); MDNS.addServiceTxt("osc", "udp", "node_type", NODE_TYPE); MDNS.addServiceTxt("osc", "udp", "node_id", NODE_ID); - // For project discovery (_datt_flower._tcp) MDNS.addService("datt_flower", "tcp", OSC_PORT); MDNS.addServiceTxt("datt_flower", "tcp", "node_type", NODE_TYPE); MDNS.addServiceTxt("datt_flower", "tcp", "node_id", NODE_ID); + mdnsStarted = true; Serial.printf("[Net] mDNS ready: %s.local\n", NODE_ID); } +void ensureMDNS() { + if (mdnsStarted) return; + if (WiFi.status() == WL_CONNECTED) { + setupMDNS(); + } +} + +void printWifiStatus() { + wl_status_t st = WiFi.status(); + Serial.println("\n=== WiFi Status ==="); + Serial.printf("SSID: %s\n", STA_SSID); + Serial.printf("Status: %d\n", (int)st); + if (st == WL_CONNECTED) { + Serial.printf("IP: %d.%d.%d.%d\n", WiFi.localIP()[0], WiFi.localIP()[1], WiFi.localIP()[2], WiFi.localIP()[3]); + Serial.printf("RSSI: %d dBm\n", WiFi.RSSI()); + } else { + Serial.println("IP: (not connected)"); + } + Serial.println("===================\n"); +} + +void manualWifiRetry(int attempts) { + wifiManualRetryAttempts = constrain(attempts, 1, 120); + Serial.printf("[Net] Manual retry, attempts=%d\n", wifiManualRetryAttempts); + bool ok = connectWifiWithAttempts(wifiManualRetryAttempts, true); + if (ok) ensureMDNS(); +} + +// ============================================================ +// Servo setup +// ============================================================ void setupServos() { ESP32PWM::allocateTimer(0); ESP32PWM::allocateTimer(1); @@ -217,14 +391,13 @@ void setupServos() { for (int i = 0; i < 4; i++) { servosX[i].setPeriodHertz(SERVO_HZ); servosY[i].setPeriodHertz(SERVO_HZ); - servosX[i].attach(pinsX[i], SERVO_MIN_US, SERVO_MAX_US); servosY[i].attach(pinsY[i], SERVO_MIN_US, SERVO_MAX_US); } - setAllServos(90, 90); - currentPan = targetPan = 90; - currentTilt = targetTilt = 90; + centerAllModes(); + clampAllStateToLimits(); + writeServosNow(); } // ============================================================ @@ -232,13 +405,14 @@ void setupServos() { // ============================================================ void routeTrackAuto(OSCMessage& msg, int addrOffset) { if (!msg.isInt(0)) return; - autoTracking = (msg.getInt(0) != 0); - Serial.printf("[OSC] /track/auto -> %s\n", autoTracking ? "ON" : "OFF"); + setControlMode(msg.getInt(0) != 0 ? MODE_TRACKING : MODE_MANUAL); } -void routeTrackNorm(OSCMessage& msg, int addrOffset) { - if (!autoTracking) return; +void routeTrackMode(OSCMessage& msg, int addrOffset) { + routeTrackAuto(msg, addrOffset); +} +void routeTrackNorm(OSCMessage& msg, int addrOffset) { float nx = 0.5f; float ny = 0.5f; if (msg.isFloat(0)) nx = msg.getFloat(0); @@ -247,26 +421,22 @@ void routeTrackNorm(OSCMessage& msg, int addrOffset) { if (msg.isFloat(1)) ny = msg.getFloat(1); else if (msg.isInt(1)) ny = (float)msg.getInt(1); - applyNormTarget(nx, ny); + setTrackByNorm(nx, ny); } void routeTrackXY(OSCMessage& msg, int addrOffset) { - if (!autoTracking) return; if (!msg.isInt(0) || !msg.isInt(1)) return; - int x = msg.getInt(0); int y = msg.getInt(1); int frameW = FRAME_WIDTH_DEFAULT; int frameH = FRAME_HEIGHT_DEFAULT; - if (msg.isInt(2)) frameW = msg.getInt(2); if (msg.isInt(3)) frameH = msg.getInt(3); - - applyPixelTarget(x, y, frameW, frameH); + setTrackByPixel(x, y, frameW, frameH); } void routeTrackCenter(OSCMessage& msg, int addrOffset) { - applyTargetAngles(90, 90); + centerAllModes(); Serial.println("[OSC] /track/center"); } @@ -276,22 +446,18 @@ void routeTrackSmooth(OSCMessage& msg, int addrOffset) { Serial.printf("[OSC] /track/smoothing -> %d%%\n", smoothFactorPct); } +void routeTrackLimits(OSCMessage& msg, int addrOffset) { + if (!msg.isInt(0) || !msg.isInt(1) || !msg.isInt(2) || !msg.isInt(3)) return; + applyAngleLimits(msg.getInt(0), msg.getInt(1), msg.getInt(2), msg.getInt(3)); +} + void routeFlowerDirect(OSCMessage& msg, int flowerIdx) { if (flowerIdx < 0 || flowerIdx > 3) return; if (!msg.isInt(0) || !msg.isInt(1)) return; - autoTracking = false; - int pan = constrain(msg.getInt(0), 0, 180); - int tilt = constrain(msg.getInt(1), 0, 180); - - servosX[flowerIdx].write(pan); - servosY[flowerIdx].write(tilt); - - currentPan = pan; - currentTilt = tilt; - targetPan = pan; - targetTilt = tilt; - + int pan = msg.getInt(0); + int tilt = msg.getInt(1); + setManualFlower(flowerIdx, pan, tilt, true); Serial.printf("[OSC] /flower%d %d %d\n", flowerIdx + 1, pan, tilt); } @@ -304,17 +470,15 @@ void routeInfoSelf(OSCMessage& msg, int addrOffset) { OSCMessage reply("/info/self"); reply.add(NODE_ID); - uint8_t mac[6]; WiFi.macAddress(mac); char macStr[18]; sprintf(macStr, "%02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); reply.add(macStr); + reply.add("STA"); - reply.add(WiFi.getMode() == WIFI_AP ? "AP" : "STA"); - - IPAddress ip = (WiFi.getMode() == WIFI_AP) ? WiFi.softAPIP() : WiFi.localIP(); + IPAddress ip = WiFi.localIP(); char ipStr[16]; sprintf(ipStr, "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); reply.add(ipStr); @@ -327,12 +491,16 @@ void routeInfoSelf(OSCMessage& msg, int addrOffset) { void routeInfoServo(OSCMessage& msg, int addrOffset) { OSCMessage reply("/info/servo"); - reply.add((int32_t)(autoTracking ? 1 : 0)); - reply.add((int32_t)currentPan); - reply.add((int32_t)currentTilt); - reply.add((int32_t)targetPan); - reply.add((int32_t)targetTilt); + reply.add((int32_t)(controlMode == MODE_TRACKING ? 1 : 0)); + reply.add((int32_t)round(currentPan[0])); + reply.add((int32_t)round(currentTilt[0])); + reply.add((int32_t)targetPan[0]); + reply.add((int32_t)targetTilt[0]); reply.add((int32_t)smoothFactorPct); + reply.add((int32_t)panMinDeg); + reply.add((int32_t)panMaxDeg); + reply.add((int32_t)tiltMinDeg); + reply.add((int32_t)tiltMaxDeg); udp.beginPacket(udp.remoteIP(), udp.remotePort()); reply.send(udp); @@ -343,6 +511,25 @@ void routeInfoServo(OSCMessage& msg, int addrOffset) { // ============================================================ // Serial commands // ============================================================ +void printSelfInfo() { + uint8_t mac[6]; + WiFi.macAddress(mac); + IPAddress ip = WiFi.localIP(); + + Serial.println("\n=== Face Tracking Node Info ==="); + Serial.printf("Node ID: %s\n", NODE_ID); + Serial.printf("Node Type: %s\n", NODE_TYPE); + Serial.printf("Mode: %s\n", controlMode == MODE_TRACKING ? "TRACKING" : "MANUAL"); + Serial.printf("IP: %d.%d.%d.%d\n", ip[0], ip[1], ip[2], ip[3]); + Serial.printf("MAC: %02X:%02X:%02X:%02X:%02X:%02X\n", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + Serial.printf("Track Pan/Tilt: %d / %d\n", trackingPan, trackingTilt); + Serial.printf("Blend (tracking): %.2f\n", modeBlend); + Serial.printf("Limits Pan=%d..%d Tilt=%d..%d\n", panMinDeg, panMaxDeg, tiltMinDeg, tiltMaxDeg); + Serial.printf("Smooth=%d%% Deadband=%d MaxStep=%d\n", smoothFactorPct, deadbandDeg, maxStepDeg); + Serial.println("===============================\n"); +} + void parseSerialLine() { if (!Serial.available()) return; @@ -359,30 +546,80 @@ void parseSerialLine() { return; } if (line.equals("center")) { - applyTargetAngles(90, 90); + centerAllModes(); + return; + } + + if (line.equals("wifi status")) { + printWifiStatus(); + return; + } + if (line.startsWith("wifi retry")) { + int attempts = wifiManualRetryAttempts; + sscanf(line.c_str(), "wifi retry %d", &attempts); + manualWifiRetry(attempts); return; } if (line.startsWith("auto")) { - int v = 0; + int v = 1; sscanf(line.c_str(), "auto %d", &v); - autoTracking = (v != 0); - Serial.printf("[Serial] auto=%d\n", autoTracking ? 1 : 0); + setControlMode(v != 0 ? MODE_TRACKING : MODE_MANUAL); return; } + if (line.startsWith("mode")) { + int v = -1; + if (sscanf(line.c_str(), "mode %d", &v) == 1) { + setControlMode(v != 0 ? MODE_TRACKING : MODE_MANUAL); + return; + } + if (line.endsWith("track")) { + setControlMode(MODE_TRACKING); + return; + } + if (line.endsWith("manual")) { + setControlMode(MODE_MANUAL); + return; + } + } + if (line.startsWith("smooth")) { - int v = 40; + int v = smoothFactorPct; sscanf(line.c_str(), "smooth %d", &v); smoothFactorPct = constrain(v, 0, 100); Serial.printf("[Serial] smoothing=%d%%\n", smoothFactorPct); return; } + if (line.startsWith("deadband")) { + int v = deadbandDeg; + sscanf(line.c_str(), "deadband %d", &v); + deadbandDeg = constrain(v, 0, 20); + Serial.printf("[Serial] deadband=%d\n", deadbandDeg); + return; + } + + if (line.startsWith("step")) { + int v = maxStepDeg; + sscanf(line.c_str(), "step %d", &v); + maxStepDeg = constrain(v, 1, 45); + Serial.printf("[Serial] maxStep=%d\n", maxStepDeg); + return; + } + + if (line.startsWith("limits")) { + int pMin = panMinDeg, pMax = panMaxDeg, tMin = tiltMinDeg, tMax = tiltMaxDeg; + if (sscanf(line.c_str(), "limits %d %d %d %d", &pMin, &pMax, &tMin, &tMax) == 4) { + applyAngleLimits(pMin, pMax, tMin, tMax); + } + return; + } + if (line.startsWith("norm")) { float nx = 0.5f, ny = 0.5f; if (sscanf(line.c_str(), "norm %f %f", &nx, &ny) == 2) { - applyNormTarget(nx, ny); + setTrackByNorm(nx, ny); Serial.printf("[Serial] norm=%.3f,%.3f\n", nx, ny); } return; @@ -393,10 +630,8 @@ void parseSerialLine() { if (commaIdx > 0) { int x = line.substring(0, commaIdx).toInt(); int y = line.substring(commaIdx + 1).toInt(); - if (autoTracking) { - applyPixelTarget(x, y, FRAME_WIDTH_DEFAULT, FRAME_HEIGHT_DEFAULT); - Serial.printf("[Serial] xy=%d,%d\n", x, y); - } + setTrackByPixel(x, y, FRAME_WIDTH_DEFAULT, FRAME_HEIGHT_DEFAULT); + Serial.printf("[Serial] xy=%d,%d\n", x, y); return; } @@ -404,7 +639,7 @@ void parseSerialLine() { int x = 0, y = 0, w = FRAME_WIDTH_DEFAULT, h = FRAME_HEIGHT_DEFAULT; int parsed = sscanf(line.c_str(), "xy %d %d %d %d", &x, &y, &w, &h); if (parsed >= 2) { - applyPixelTarget(x, y, w, h); + setTrackByPixel(x, y, w, h); Serial.printf("[Serial] xy=%d,%d frame=%d,%d\n", x, y, w, h); } return; @@ -414,9 +649,7 @@ void parseSerialLine() { int idx = 0, pan = 90, tilt = 90; if (sscanf(line.c_str(), "flower%d %d %d", &idx, &pan, &tilt) == 3) { if (idx >= 1 && idx <= 4) { - autoTracking = false; - servosX[idx - 1].write(constrain(pan, 0, 180)); - servosY[idx - 1].write(constrain(tilt, 0, 180)); + setManualFlower(idx - 1, pan, tilt, true); Serial.printf("[Serial] flower%d=%d,%d\n", idx, pan, tilt); } } @@ -428,17 +661,23 @@ void parseSerialLine() { void printHelp() { Serial.println("\n=== Face Tracking Commands ==="); - Serial.println("help - show this help"); - Serial.println("info - show device info"); - Serial.println("auto <0|1> - auto tracking off/on"); - Serial.println("center - move all servos to center"); - Serial.println("smooth <0-100> - tracking smoothing"); - Serial.println("norm - normalized coordinate (0.0-1.0)"); - Serial.println("xy [w h] - pixel coordinate"); - Serial.println("x,y - legacy pixel coordinate"); - Serial.println("flower - direct single flower control"); - Serial.println("OSC: /track/auto /track/norm /track/xy /track/center /track/smoothing"); - Serial.println("OSC: /flower1..4 /info/self /info/servo"); + Serial.println("help - show this help"); + Serial.println("info - show device info"); + Serial.println("center - move all groups to center"); + Serial.println("auto <0|1> - MANUAL/TRACKING mode"); + Serial.println("mode <0|1|manual|track> - mode switch"); + Serial.println("smooth <0-100> - smoothing percent"); + Serial.println("deadband <0-20> - servo deadband in deg"); + Serial.println("step <1-45> - max servo step per update"); + Serial.println("limits "); + Serial.println("norm - normalized coordinate (0.0-1.0)"); + Serial.println("xy [w h] - pixel coordinate"); + Serial.println("x,y - legacy pixel coordinate"); + Serial.println("flower - direct single flower control"); + Serial.println("wifi status - print WiFi status"); + Serial.println("wifi retry - manual STA reconnect tries"); + Serial.println("OSC: /track/auto /track/mode /track/norm /track/xy /track/center"); + Serial.println("OSC: /track/smoothing /track/limits /flower1..4 /info/self /info/servo"); Serial.println("==============================\n"); } @@ -447,6 +686,7 @@ void printHelp() { // ============================================================ void setup() { Serial.begin(SERIAL_BAUD); + Serial.setTimeout(SERIAL_TIMEOUT_MS); Serial.println("\n========== DATT3700 Face Tracking Node =========="); setupServos(); @@ -468,10 +708,12 @@ void loop() { if (!msg.hasError()) { msg.route("/track/auto", routeTrackAuto); + msg.route("/track/mode", routeTrackMode); msg.route("/track/norm", routeTrackNorm); msg.route("/track/xy", routeTrackXY); msg.route("/track/center", routeTrackCenter); msg.route("/track/smoothing", routeTrackSmooth); + msg.route("/track/limits", routeTrackLimits); msg.route("/flower1", routeFlower1); msg.route("/flower2", routeFlower2); @@ -484,6 +726,7 @@ void loop() { } parseSerialLine(); + ensureWifiConnected(); + ensureMDNS(); updateServos(); } - diff --git a/esp32_firmware_refactored/sue_main/sue_main.ino b/esp32_firmware_refactored/sue_main/sue_main.ino index 2310b12..1980c27 100644 --- a/esp32_firmware_refactored/sue_main/sue_main.ino +++ b/esp32_firmware_refactored/sue_main/sue_main.ino @@ -1,573 +1,804 @@ -/** - * sue_main.ino - Sue Single-Flower Node with FSM Servo Control - * Sue 单花节点 - 有限状态机舵机控制 - * - * Hardware / 硬件: - * - 1× Servo motor (GPIO 18) — petal open/close via L298N or direct - * - 1× Red LED (GPIO 22) — danger indicator - * - 1× Green LED (GPIO 23) — relax indicator - * - * FSM States / 有限状态机: - * IDLE → Flower closed, waiting for command / 花朵闭合,等待指令 - * OPENING → Servo smoothly moving 60° → 120° / 舵机平滑打开 - * OPENED → Flower open, holding position / 花朵开放,保持位置 - * CLOSING → Servo smoothly moving 120° → 60° / 舵机平滑闭合 - * - * Network / 网络: - * WiFi STA/AP + mDNS + UDP/OSC (NO ESPAsyncWebServer) - * Default: STA mode (connects to router / 默认客户端模式) - * - * OSC Commands / OSC 指令: - * /state [danger|relax|idle|alert|calm] - Set flower state / 设置花朵状态 - * /angle [value] - Direct servo angle (0-180) / 直接设置舵机角度 - * /speed [value] - Set servo step speed (ms/degree) / 设置舵机速度 - * /led [r] [g] - Direct LED control / 直接控制 LED - * /stop - Emergency stop / 紧急停止 - * - * Constraints / 约束: - * ⚠️ NO delay() in loop() — millis() only - * ⚠️ No String concatenation in loops - * ⚠️ No malloc/new at runtime - * - * Note on PID / 关于 PID: - * Standard hobby servos have an internal PID controller — they move - * to a commanded angle using their own feedback loop. External PID - * would require a potentiometer or encoder for position feedback, - * which this wiring does not include. The smooth stepping here - * provides gentle motion profiles without needing external PID. - * 标准舵机内部已有 PID 控制器。外部 PID 需要电位器或编码器反馈, - * 当前接线不包含。此处的平滑步进已能提供柔和运动曲线。 - */ - #include #include #include #include #include +#include +#include // ============================================================ -// Network Configuration / 网络配置 +// Node / network config // ============================================================ -#define USE_AP_MODE false // false = STA (client), true = AP (hotspot) - -// AP mode settings / 热点模式设置 -const char* ap_ssid = "ESP32_Sue"; -const char* ap_password = "12345678"; - -// STA mode settings / 客户端模式设置 -// ⚠️ Change these to your actual WiFi credentials before flashing! -// ⚠️ 烧录前请修改为你实际的 WiFi 账号密码! -const char* sta_ssid = "F7OWER"; -const char* sta_password = "12345678"; +const char* STA_SSID = "F7OWER"; +const char* STA_PASSWORD = "12345678"; -// Node identification / 节点识别 -const char* NODE_ID = "sue_1"; +const char* NODE_ID = "sue_1"; const char* NODE_TYPE = "sue"; -const int OSC_PORT = 8888; +const int OSC_PORT = 8888; -// mDNS service / mDNS 服务 -const char* MDNS_SERVICE = "_datt_flower"; -const char* MDNS_PROTO = "_tcp"; - -// STA reconnection / STA 重连 -const int STA_MAX_RETRIES = 20; -const int STA_RETRY_INTERVAL = 500; // ms -bool staConnecting = false; -int staRetryCount = 0; -unsigned long lastSTACheckMs = 0; -bool networkConnected = false; +const int WIFI_BOOT_CONNECT_ATTEMPTS = 24; +const int WIFI_AUTO_RETRY_ATTEMPTS = 10; +const int WIFI_MANUAL_RETRY_DEFAULT = 6; +const int WIFI_RETRY_DELAY_MS = 500; +const unsigned long WIFI_RETRY_INTERVAL_MS = 6000; // ============================================================ -// Hardware Pins / 硬件引脚 +// Display pins (GC9A01 round TFT) // ============================================================ -const int SERVO_PIN = 18; -const int RED_LED = 22; -const int GREEN_LED = 23; +#define TFT_MOSI 23 +#define TFT_SCLK 18 +#define TFT_CS 19 +#define TFT_DC 21 +#define TFT_RST 22 + +Arduino_DataBus* bus = new Arduino_ESP32SPI(TFT_DC, TFT_CS, TFT_SCLK, TFT_MOSI, -1); +Arduino_GFX* panel = new Arduino_GC9A01(bus, TFT_RST, 0); + +const int PATCH_X = 52; +const int PATCH_Y = 48; +const int PATCH_W = 136; +const int PATCH_H = 144; +const int EYE_CX = 120; +const int EYE_CY = 120; +const int LCX = EYE_CX - PATCH_X; +const int LCY = EYE_CY - PATCH_Y; + +Arduino_GFX* eyeCanvasA = new Arduino_Canvas(PATCH_W, PATCH_H, panel, PATCH_X, PATCH_Y); +Arduino_GFX* eyeCanvasB = new Arduino_Canvas(PATCH_W, PATCH_H, panel, PATCH_X, PATCH_Y); +Arduino_GFX* frontCanvas = eyeCanvasA; +Arduino_GFX* backCanvas = eyeCanvasB; // ============================================================ -// Servo Configuration / 舵机配置 +// Servo config (physical-safe travel) // ============================================================ -const int CLOSED_ANGLE = 60; // Flower closed position / 花朵闭合角度 -const int OPEN_ANGLE = 120; // Flower open position / 花朵开放角度 +Servo petalServo; +const int SERVO_PIN = 4; +const int SERVO_MIN_US = 500; +const int SERVO_MAX_US = 2400; +const int SERVO_SAFE_MIN_ANGLE = 66; +const int SERVO_SAFE_MAX_ANGLE = 118; +const int PETAL_CLOSED_ANGLE = 72; +const int PETAL_OPEN_ANGLE = 110; +const int PETAL_ALERT_ANGLE = 88; + +enum PetalMode { + PETAL_IDLE = 0, + PETAL_MOVING = 1, + PETAL_BREATHING = 2 +}; + +PetalMode petalMode = PETAL_IDLE; +int currentPetalAngle = PETAL_CLOSED_ANGLE; +int targetPetalAngle = PETAL_CLOSED_ANGLE; +int petalStepIntervalMs = 16; +unsigned long lastPetalStepMs = 0; +int breathMinPct = 35; +int breathMaxPct = 75; +int breathPeriodMs = 3800; +unsigned long breathStartMs = 0; // ============================================================ -// FSM States / 有限状态机状态 +// Eye runtime state // ============================================================ -enum FlowerState { - STATE_IDLE, // Closed, waiting / 闭合,等待 - STATE_OPENING, // Smoothly opening / 平滑打开中 - STATE_OPENED, // Open, holding / 开放,保持 - STATE_CLOSING // Smoothly closing / 平滑闭合中 +struct EyeState { + float gazeX; + float gazeY; + float targetX; + float targetY; + float gazeLimitX; + float gazeLimitY; + float manualOpen; + float pupilSpinPhase; + bool manualOpenOverride; + bool trackEnabled; + bool autoBlink; + bool autoBreathe; + bool pupilAutoSpin; + bool blinkRunning; + unsigned long blinkStartMs; + unsigned long blinkDurationMs; + unsigned long nextBlinkMs; + unsigned long lastTrackInputMs; +}; + +EyeState eye = { + 0.0f, 0.0f, + 0.0f, 0.0f, + 1.0f, 1.0f, + 1.0f, + 0.0f, + false, + true, true, true, true, + false, 0, 180, 0, 0 }; +const unsigned long EYE_FRAME_INTERVAL_MS = 33; +const unsigned long TRACK_HOLD_TIMEOUT_MS = 1400; +unsigned long lastEyeFrameMs = 0; + // ============================================================ -// Global State / 全局状态 +// Network runtime // ============================================================ -Servo petalServo; WiFiUDP udp; - -FlowerState currentState = STATE_IDLE; -int currentAngle = CLOSED_ANGLE; -int targetAngle = CLOSED_ANGLE; -int stepIntervalMs = 20; // ms per degree step / 每度步进间隔 -unsigned long lastStepMs = 0; // Last servo step timestamp / 上次步进时间 -unsigned long stateEntryMs = 0; // When current state was entered / 进入当前状态的时间 - -// Auto-close timer for OPENED state (0 = disabled) / 自动闭合定时器(0=禁用) -unsigned long autoCloseMs = 0; +unsigned long lastWifiRetryMs = 0; +int wifiManualRetryAttempts = WIFI_MANUAL_RETRY_DEFAULT; +bool mdnsStarted = false; // ============================================================ -// setup() / 初始化 +// Colors // ============================================================ -void setup() { - Serial.begin(115200); - - Serial.println("\n========================================"); - Serial.println(" DATT3700 Flower Node - Sue"); - Serial.println(" Single-Flower Servo Controller"); - Serial.println(" 单花舵机控制节点"); - Serial.println("========================================\n"); +const uint16_t COLOR_BLACK = 0x0000; +const uint16_t COLOR_WHITE = 0xFFFF; +const uint16_t COLOR_SCLERA = 0xEF7D; +const uint16_t COLOR_IRIS_DARK = 0x0015; +const uint16_t COLOR_IRIS_MID = 0x027F; +const uint16_t COLOR_IRIS_LIGHT = 0x3DFF; +const uint16_t COLOR_SHADOW = 0x4208; +const uint16_t COLOR_LID = 0x20C4; - // --- Initialize hardware / 初始化硬件 --- - // Configure LED pins with LEDC PWM for brightness control / 用 LEDC PWM 配置 LED 引脚 - ledcAttach(RED_LED, 1000, 8); - ledcAttach(GREEN_LED, 1000, 8); - ledcWrite(RED_LED, 0); - ledcWrite(GREEN_LED, 0); - - petalServo.setPeriodHertz(50); - petalServo.attach(SERVO_PIN, 500, 2400); - petalServo.write(CLOSED_ANGLE); - currentAngle = CLOSED_ANGLE; - targetAngle = CLOSED_ANGLE; - - Serial.printf("[Sue] Servo on GPIO %d, range %d-%d degrees\n", - SERVO_PIN, CLOSED_ANGLE, OPEN_ANGLE); - Serial.printf("[Sue] Red LED: GPIO %d, Green LED: GPIO %d\n", - RED_LED, GREEN_LED); - - // --- Initialize network / 初始化网络 --- - setupNetwork(); - - // --- Start UDP for OSC / 启动 OSC UDP --- - udp.begin(OSC_PORT); - Serial.printf("[Sue] OSC listening on port %d\n", OSC_PORT); +// ============================================================ +// Forward declarations +// ============================================================ +bool connectWifiWithAttempts(int attempts, bool verbose); +void setupNetwork(); +void ensureWifiConnected(); +void setupMDNS(); +void ensureMDNS(); +void printWifiStatus(); +void manualWifiRetry(int attempts); + +void processOSC(); +void parseSerialLine(); +void printHelp(); +void printSelfInfo(); + +void setPetalOpenPercent(int pct, bool smooth = true); +void setPetalAngleSafe(int angle, bool smooth = true); +void startPetalBreathe(int minPct, int maxPct, int periodMs); +void stopPetalBreathe(); +void updatePetal(); +void applyState(const char* state); +void emergencyStop(); + +void setTrackNorm(float nx, float ny); +void setTrackPixel(int x, int y, int frameW, int frameH); +void setManualEyeOpenPercent(int pct); +void setManualEyeLook(float x, float y); +void updateEye(bool force = false); +void drawEyeFrame( + Arduino_GFX* c, + float gazeX, + float gazeY, + float openFactor, + float breatheScale, + float breatheDrift, + float pupilSpinPhase, + bool pupilAutoSpin +); +void drawStaticBackground(); +unsigned long chooseNextBlinkDelayMs(); + +// OSC routes +void routeState(OSCMessage& msg, int addrOffset); +void routeAngle(OSCMessage& msg, int addrOffset); +void routeOpen(OSCMessage& msg, int addrOffset); +void routeSpeed(OSCMessage& msg, int addrOffset); +void routeStop(OSCMessage& msg, int addrOffset); +void routeTrackAuto(OSCMessage& msg, int addrOffset); +void routeTrackNorm(OSCMessage& msg, int addrOffset); +void routeTrackXY(OSCMessage& msg, int addrOffset); +void routeTrackCenter(OSCMessage& msg, int addrOffset); +void routeEyeLook(OSCMessage& msg, int addrOffset); +void routeEyeOpen(OSCMessage& msg, int addrOffset); +void routeEyeBlink(OSCMessage& msg, int addrOffset); +void routeEyeBreathe(OSCMessage& msg, int addrOffset); +void routeEyeLimits(OSCMessage& msg, int addrOffset); +void routeEyePupilAuto(OSCMessage& msg, int addrOffset); +void routeInfoSelf(OSCMessage& msg, int addrOffset); +void routeInfoServo(OSCMessage& msg, int addrOffset); + +float clampf(float v, float lo, float hi) { + if (v < lo) return lo; + if (v > hi) return hi; + return v; +} - // --- Print command help / 打印命令帮助 --- - printHelp(); +int mapOpenPctToAngle(int pct) { + pct = constrain(pct, 0, 100); + int angle = PETAL_CLOSED_ANGLE + ((PETAL_OPEN_ANGLE - PETAL_CLOSED_ANGLE) * pct) / 100; + return constrain(angle, SERVO_SAFE_MIN_ANGLE, SERVO_SAFE_MAX_ANGLE); +} - Serial.println("[Sue] System ready! / 系统就绪!\n"); +unsigned long chooseNextBlinkDelayMs() { + return (unsigned long)random(2500, 6800); } // ============================================================ -// loop() — Non-blocking / 非阻塞主循环 -// ⚠️ NO delay() allowed +// Network // ============================================================ -void loop() { - // 1. Network maintenance / 网络维护 - updateNetwork(); - - // 2. Process OSC messages / 处理 OSC 消息 - processOSC(); - - // 3. FSM servo update / 状态机舵机更新 - updateFSM(); +bool connectWifiWithAttempts(int attempts, bool verbose) { + attempts = constrain(attempts, 1, 120); + WiFi.mode(WIFI_STA); + WiFi.setSleep(false); + WiFi.setAutoReconnect(true); + WiFi.persistent(false); + WiFi.begin(STA_SSID, STA_PASSWORD); + + if (verbose) Serial.print("[Net] Connecting"); + for (int i = 0; i < attempts; i++) { + if (WiFi.status() == WL_CONNECTED) { + if (verbose) { + Serial.print("\n[Net] Connected, IP: "); + Serial.println(WiFi.localIP()); + } + return true; + } + delay(WIFI_RETRY_DELAY_MS); + if (verbose) Serial.print("."); + } - // 4. Serial debug commands / 串口调试命令 - processSerial(); + if (verbose) Serial.println("\n[Net] STA connect failed"); + return WiFi.status() == WL_CONNECTED; } -// ============================================================ -// Network Setup / 网络设置 -// ============================================================ void setupNetwork() { - if (USE_AP_MODE) { - Serial.println("[Net] Starting AP mode... / 正在启动热点模式..."); - WiFi.softAP(ap_ssid, ap_password); - Serial.printf("[Net] AP started. SSID: %s IP: %s\n", - ap_ssid, WiFi.softAPIP().toString().c_str()); - networkConnected = true; - - // Start mDNS immediately in AP mode / AP 模式立即启动 mDNS - startMDNS(); - } else { - Serial.printf("[Net] Connecting to: %s\n", sta_ssid); - WiFi.mode(WIFI_STA); - WiFi.begin(sta_ssid, sta_password); - staConnecting = true; - staRetryCount = 0; - lastSTACheckMs = millis(); + if (!connectWifiWithAttempts(WIFI_BOOT_CONNECT_ATTEMPTS, true)) { + Serial.println("[Net] Boot without WiFi, auto-retry enabled"); } } -void updateNetwork() { - if (USE_AP_MODE) return; // AP mode needs no maintenance / AP 模式无需维护 - +void ensureWifiConnected() { + if (WiFi.status() == WL_CONNECTED) return; unsigned long now = millis(); - - if (staConnecting) { - if (now - lastSTACheckMs >= STA_RETRY_INTERVAL) { - lastSTACheckMs = now; - - if (WiFi.status() == WL_CONNECTED) { - networkConnected = true; - staConnecting = false; - Serial.printf("[Net] Connected! IP: %s\n", WiFi.localIP().toString().c_str()); - startMDNS(); - } else { - staRetryCount++; - Serial.printf("[Net] Connecting... attempt %d/%d\n", - staRetryCount, STA_MAX_RETRIES); - - if (staRetryCount >= STA_MAX_RETRIES) { - Serial.println("[Net] STA failed. Falling back to AP mode..."); - staConnecting = false; - WiFi.disconnect(); - WiFi.softAP(ap_ssid, ap_password); - Serial.printf("[Net] AP fallback. SSID: %s IP: %s\n", - ap_ssid, WiFi.softAPIP().toString().c_str()); - networkConnected = true; - startMDNS(); - } - } - } - } else if (networkConnected && WiFi.status() != WL_CONNECTED) { - networkConnected = false; - Serial.println("[Net] Connection lost. Reconnecting..."); - WiFi.reconnect(); - staConnecting = true; - staRetryCount = 0; - lastSTACheckMs = millis(); - } + if (now - lastWifiRetryMs < WIFI_RETRY_INTERVAL_MS) return; + lastWifiRetryMs = now; + Serial.println("[Net] WiFi disconnected, retrying..."); + connectWifiWithAttempts(WIFI_AUTO_RETRY_ATTEMPTS, false); } -void startMDNS() { +void setupMDNS() { + if (mdnsStarted) return; + if (WiFi.status() != WL_CONNECTED) return; if (!MDNS.begin(NODE_ID)) { Serial.println("[Net] mDNS failed"); return; } - MDNS.addService(MDNS_SERVICE, MDNS_PROTO, OSC_PORT); - MDNS.addServiceTxt(MDNS_SERVICE, MDNS_PROTO, "node_type", NODE_TYPE); - MDNS.addServiceTxt(MDNS_SERVICE, MDNS_PROTO, "node_id", NODE_ID); - Serial.printf("[Net] mDNS: %s.local Service: %s.%s\n", - NODE_ID, MDNS_SERVICE, MDNS_PROTO); + MDNS.addService("osc", "udp", OSC_PORT); + MDNS.addServiceTxt("osc", "udp", "node_type", NODE_TYPE); + MDNS.addServiceTxt("osc", "udp", "node_id", NODE_ID); + MDNS.addService("datt_flower", "tcp", OSC_PORT); + MDNS.addServiceTxt("datt_flower", "tcp", "node_type", NODE_TYPE); + MDNS.addServiceTxt("datt_flower", "tcp", "node_id", NODE_ID); + mdnsStarted = true; + Serial.printf("[Net] mDNS ready: %s.local\n", NODE_ID); } -// ============================================================ -// OSC Processing / OSC 处理 -// ============================================================ -void processOSC() { - int packetSize = udp.parsePacket(); - if (packetSize <= 0) return; - - OSCMessage msg; - while (packetSize--) { - msg.fill(udp.read()); +void ensureMDNS() { + if (!mdnsStarted && WiFi.status() == WL_CONNECTED) { + setupMDNS(); } +} - if (msg.hasError()) { - Serial.println("[OSC] Message error"); - return; +void printWifiStatus() { + wl_status_t st = WiFi.status(); + Serial.println("\n=== WiFi Status ==="); + Serial.printf("SSID: %s\n", STA_SSID); + Serial.printf("Status: %d\n", (int)st); + if (st == WL_CONNECTED) { + Serial.printf("IP: %d.%d.%d.%d\n", WiFi.localIP()[0], WiFi.localIP()[1], WiFi.localIP()[2], WiFi.localIP()[3]); + Serial.printf("RSSI: %d dBm\n", WiFi.RSSI()); + } else { + Serial.println("IP: (not connected)"); } + Serial.println("===================\n"); +} - char address[64]; - msg.getAddress(address, 0, sizeof(address)); - Serial.printf("[OSC] Received: %s\n", address); +void manualWifiRetry(int attempts) { + wifiManualRetryAttempts = constrain(attempts, 1, 120); + Serial.printf("[Net] Manual retry, attempts=%d\n", wifiManualRetryAttempts); + bool ok = connectWifiWithAttempts(wifiManualRetryAttempts, true); + if (ok) ensureMDNS(); +} - // Route OSC messages / 路由 OSC 消息 - if (strcmp(address, "/state") == 0) { - handleStateOSC(msg); - } - else if (strcmp(address, "/angle") == 0) { - if (msg.isInt(0)) { - int angle = constrain(msg.getInt(0), 0, 180); - setTargetAngle(angle); - Serial.printf("[OSC] Direct angle: %d\n", angle); - } - } - else if (strcmp(address, "/speed") == 0) { - if (msg.isInt(0)) { - stepIntervalMs = constrain(msg.getInt(0), 1, 200); - Serial.printf("[OSC] Step speed: %d ms/deg\n", stepIntervalMs); - } - } - else if (strcmp(address, "/led") == 0) { - if (msg.isInt(0) && msg.isInt(1)) { - int r = msg.getInt(0); - int g = msg.getInt(1); - ledcWrite(RED_LED, constrain(r, 0, 255)); - ledcWrite(GREEN_LED, constrain(g, 0, 255)); - Serial.printf("[OSC] LED: R=%d G=%d\n", r, g); - } - } - else if (strcmp(address, "/stop") == 0) { - emergencyStop(); +// ============================================================ +// Petal / state control +// ============================================================ +void setPetalAngleSafe(int angle, bool smooth) { + int safe = constrain(angle, SERVO_SAFE_MIN_ANGLE, SERVO_SAFE_MAX_ANGLE); + targetPetalAngle = safe; + if (!smooth) { + currentPetalAngle = safe; + petalServo.write(currentPetalAngle); + petalMode = PETAL_IDLE; + } else { + petalMode = PETAL_MOVING; } } -void handleStateOSC(OSCMessage &msg) { - // Accept string or int state commands / 接受字符串或整数状态指令 - if (msg.isString(0)) { - char state[32]; - msg.getString(0, state, sizeof(state)); - applyState(state); - } else if (msg.isInt(0)) { - int stateNum = msg.getInt(0); - switch (stateNum) { - case 0: applyState("idle"); break; - case 1: applyState("relax"); break; - case 2: applyState("danger"); break; - case 3: applyState("alert"); break; - case 4: applyState("calm"); break; - case 5: applyState("breathe"); break; - } - } +void setPetalOpenPercent(int pct, bool smooth) { + setPetalAngleSafe(mapOpenPctToAngle(pct), smooth); } -// ============================================================ -// State Presets / 状态预设 -// ============================================================ -void applyState(const char* state) { - Serial.printf("[Sue] State: %s\n", state); +void startPetalBreathe(int minPct, int maxPct, int periodMs) { + breathMinPct = constrain(minPct, 0, 100); + breathMaxPct = constrain(maxPct, 0, 100); + if (breathMinPct > breathMaxPct) { + int t = breathMinPct; + breathMinPct = breathMaxPct; + breathMaxPct = t; + } + breathPeriodMs = constrain(periodMs, 800, 12000); + breathStartMs = millis(); + petalMode = PETAL_BREATHING; +} - if (strcmp(state, "danger") == 0) { - // Danger: red LED + close flower / 危险:红灯 + 闭合花朵 - ledcWrite(RED_LED, 255); - ledcWrite(GREEN_LED, 0); - startClosing(); +void stopPetalBreathe() { + if (petalMode == PETAL_BREATHING) { + petalMode = PETAL_IDLE; } - else if (strcmp(state, "relax") == 0) { - // Relax: green LED + open flower / 放松:绿灯 + 开放花朵 - ledcWrite(RED_LED, 0); - ledcWrite(GREEN_LED, 255); - startOpening(); - } - else if (strcmp(state, "idle") == 0) { - // Idle: all off, close flower / 待机:全灭,闭合花朵 - ledcWrite(RED_LED, 0); - ledcWrite(GREEN_LED, 0); - startClosing(); - } - else if (strcmp(state, "alert") == 0) { - // Alert: both LEDs on, half-open / 警戒:双灯亮,半开 - ledcWrite(RED_LED, 255); - ledcWrite(GREEN_LED, 255); - setTargetAngle((CLOSED_ANGLE + OPEN_ANGLE) / 2); - currentState = STATE_OPENING; - stateEntryMs = millis(); - } - else if (strcmp(state, "calm") == 0) { - // Calm: green LED, slow open / 平静:绿灯,慢开 - ledcWrite(RED_LED, 0); - ledcWrite(GREEN_LED, 255); - stepIntervalMs = 40; // Slower / 更慢 - startOpening(); +} + +void updatePetal() { + unsigned long now = millis(); + if (petalMode == PETAL_BREATHING) { + float phase = (float)(now - breathStartMs) / (float)breathPeriodMs; + float wave = 0.5f + 0.5f * sinf(phase * 2.0f * PI); + int pct = breathMinPct + (int)roundf((float)(breathMaxPct - breathMinPct) * wave); + int angle = mapOpenPctToAngle(pct); + currentPetalAngle = angle; + targetPetalAngle = angle; + petalServo.write(angle); + return; } - else if (strcmp(state, "breathe") == 0) { - // Breathe: open then auto-close after 3s / 呼吸:开后 3 秒自动闭合 - ledcWrite(RED_LED, 0); - ledcWrite(GREEN_LED, 255); - startOpening(); - autoCloseMs = 3000; + + if (petalMode != PETAL_MOVING) return; + if (now - lastPetalStepMs < (unsigned long)petalStepIntervalMs) return; + lastPetalStepMs = now; + + if (currentPetalAngle < targetPetalAngle) { + currentPetalAngle++; + petalServo.write(currentPetalAngle); + } else if (currentPetalAngle > targetPetalAngle) { + currentPetalAngle--; + petalServo.write(currentPetalAngle); + } else { + petalMode = PETAL_IDLE; } - else { - Serial.printf("[Sue] Unknown state: %s\n", state); +} + +void applyState(const char* state) { + if (!state) return; + Serial.printf("[Sue] state=%s\n", state); + + if (strcmp(state, "bloom") == 0 || strcmp(state, "relax") == 0) { + stopPetalBreathe(); + petalStepIntervalMs = 18; + setPetalOpenPercent(95, true); + eye.trackEnabled = true; + eye.autoBlink = true; + eye.autoBreathe = true; + eye.manualOpenOverride = false; + } else if (strcmp(state, "alert") == 0 || strcmp(state, "danger") == 0) { + stopPetalBreathe(); + petalStepIntervalMs = 8; + setPetalAngleSafe(PETAL_ALERT_ANGLE, true); + eye.trackEnabled = true; + eye.autoBlink = true; + eye.autoBreathe = false; + eye.manualOpenOverride = true; + eye.manualOpen = 0.92f; + } else if (strcmp(state, "soothe") == 0 || strcmp(state, "calm") == 0) { + petalStepIntervalMs = 24; + startPetalBreathe(45, 78, 4200); + eye.trackEnabled = true; + eye.autoBlink = true; + eye.autoBreathe = true; + eye.manualOpenOverride = false; + } else if (strcmp(state, "breathe") == 0) { + petalStepIntervalMs = 20; + startPetalBreathe(30, 85, 3600); + eye.trackEnabled = true; + eye.autoBlink = true; + eye.autoBreathe = true; + eye.manualOpenOverride = false; + } else if (strcmp(state, "rest") == 0 || strcmp(state, "idle") == 0) { + stopPetalBreathe(); + petalStepIntervalMs = 28; + setPetalOpenPercent(15, true); + eye.trackEnabled = false; + eye.autoBlink = true; + eye.autoBreathe = true; + eye.manualOpenOverride = false; + eye.targetX = 0.0f; + eye.targetY = 0.0f; } } +void emergencyStop() { + petalMode = PETAL_IDLE; + targetPetalAngle = currentPetalAngle; + eye.trackEnabled = false; + eye.targetX = 0.0f; + eye.targetY = 0.0f; + Serial.println("[Sue] emergency stop"); +} + // ============================================================ -// FSM Control / 状态机控制 +// Eye update / render // ============================================================ -void startOpening() { - targetAngle = OPEN_ANGLE; - currentState = STATE_OPENING; - stateEntryMs = millis(); +void setTrackNorm(float nx, float ny) { + nx = clampf(nx, 0.0f, 1.0f); + ny = clampf(ny, 0.0f, 1.0f); + eye.targetX = clampf(((nx * 2.0f) - 1.0f) * eye.gazeLimitX, -1.0f, 1.0f); + eye.targetY = clampf(((ny * 2.0f) - 1.0f) * eye.gazeLimitY, -1.0f, 1.0f); + eye.lastTrackInputMs = millis(); } -void startClosing() { - targetAngle = CLOSED_ANGLE; - currentState = STATE_CLOSING; - stateEntryMs = millis(); +void setTrackPixel(int x, int y, int frameW, int frameH) { + frameW = max(frameW, 1); + frameH = max(frameH, 1); + float nx = (float)constrain(x, 0, frameW) / (float)frameW; + float ny = (float)constrain(y, 0, frameH) / (float)frameH; + setTrackNorm(nx, ny); } -void setTargetAngle(int angle) { - targetAngle = constrain(angle, 0, 180); - if (targetAngle > currentAngle) { - currentState = STATE_OPENING; - } else if (targetAngle < currentAngle) { - currentState = STATE_CLOSING; - } - stateEntryMs = millis(); +void setManualEyeOpenPercent(int pct) { + eye.manualOpenOverride = true; + eye.manualOpen = clampf((float)constrain(pct, 0, 100) / 100.0f, 0.03f, 1.0f); } -void updateFSM() { - unsigned long now = millis(); +void setManualEyeLook(float x, float y) { + eye.trackEnabled = false; + eye.targetX = clampf(x * eye.gazeLimitX, -1.0f, 1.0f); + eye.targetY = clampf(y * eye.gazeLimitY, -1.0f, 1.0f); +} - switch (currentState) { - case STATE_IDLE: - // Nothing to do / 无操作 - break; - - case STATE_OPENING: - if (now - lastStepMs >= (unsigned long)stepIntervalMs) { - lastStepMs = now; - if (currentAngle < targetAngle) { - currentAngle++; - petalServo.write(currentAngle); - } else { - // Reached target / 到达目标 - currentState = STATE_OPENED; - stateEntryMs = now; - Serial.printf("[FSM] Opened at %d degrees\n", currentAngle); - } - } - break; - - case STATE_OPENED: - // Auto-close timer if set / 自动闭合定时器 - if (autoCloseMs > 0 && (now - stateEntryMs >= autoCloseMs)) { - autoCloseMs = 0; - startClosing(); - Serial.println("[FSM] Auto-closing after timer"); - } - break; - - case STATE_CLOSING: - if (now - lastStepMs >= (unsigned long)stepIntervalMs) { - lastStepMs = now; - if (currentAngle > targetAngle) { - currentAngle--; - petalServo.write(currentAngle); - } else { - // Reached target / 到达目标 - currentState = STATE_IDLE; - stateEntryMs = now; - Serial.printf("[FSM] Closed at %d degrees\n", currentAngle); - } - } - break; - } +void drawEyeFrame( + Arduino_GFX* c, + float gazeX, + float gazeY, + float openFactor, + float breatheScale, + float breatheDrift, + float pupilSpinPhase, + bool pupilAutoSpin +) { + int irisCx = LCX + (int)roundf(gazeX * 23.0f); + int irisCy = LCY + (int)roundf(gazeY * 18.0f + breatheDrift); + int irisOuter = (int)roundf(30.0f * breatheScale); + int irisMid = (int)roundf(23.0f * breatheScale); + int irisInner = (int)roundf(14.0f * breatheScale); + int pupilR = (int)roundf(11.0f * breatheScale); + int pupilCx = irisCx; + int pupilCy = irisCy; + int hiCx = pupilCx - 4; + int hiCy = pupilCy - 6; + + if (pupilAutoSpin) { + // Pupil/highlight-only orbit amount is driven by lid open factor. + float orbitR = 1.2f + (1.0f - openFactor) * 4.8f; + float dx = orbitR * cosf(pupilSpinPhase); + float dy = orbitR * sinf(pupilSpinPhase * 0.85f + 0.45f); + pupilCx += (int)roundf(dx); + pupilCy += (int)roundf(dy); + hiCx = pupilCx + (int)roundf((orbitR + 1.2f) * cosf(pupilSpinPhase + 1.85f)); + hiCy = pupilCy + (int)roundf((orbitR + 0.8f) * sinf(pupilSpinPhase + 2.2f)); + } + + c->fillScreen(COLOR_BLACK); + c->fillCircle(LCX, LCY, 66, COLOR_SCLERA); + c->fillRoundRect(20, 14, PATCH_W - 40, 18, 9, COLOR_SHADOW); + c->fillCircle(irisCx, irisCy, irisOuter, COLOR_IRIS_DARK); + c->fillCircle(irisCx, irisCy, irisMid, COLOR_IRIS_MID); + c->fillCircle(irisCx, irisCy, irisInner, COLOR_IRIS_LIGHT); + c->fillCircle(pupilCx, pupilCy, pupilR, COLOR_BLACK); + c->fillCircle(hiCx, hiCy, 4, COLOR_WHITE); + + openFactor = clampf(openFactor, 0.02f, 1.0f); + int coverPx = (int)roundf((1.0f - openFactor) * 66.0f); + int topEdge = (LCY - 66) + coverPx; + int botEdge = (LCY + 66) - coverPx; + c->fillRect(0, 0, PATCH_W, max(0, topEdge), COLOR_BLACK); + c->fillRect(0, max(0, botEdge), PATCH_W, PATCH_H - max(0, botEdge), COLOR_BLACK); + c->drawFastHLine(0, constrain(topEdge, 0, PATCH_H - 1), PATCH_W, COLOR_LID); + c->drawFastHLine(0, constrain(botEdge, 0, PATCH_H - 1), PATCH_W, COLOR_LID); } -void emergencyStop() { - currentState = STATE_IDLE; - targetAngle = currentAngle; // Stop where we are / 原地停止 - ledcWrite(RED_LED, 0); - ledcWrite(GREEN_LED, 0); - autoCloseMs = 0; - Serial.println("[Sue] Emergency stop!"); +void drawStaticBackground() { + panel->fillScreen(COLOR_BLACK); } -// ============================================================ -// Serial Debug / 串口调试 -// ============================================================ -void processSerial() { - if (Serial.available() <= 0) return; - - char cmdBuf[64]; - int len = 0; - while (Serial.available() > 0 && len < (int)(sizeof(cmdBuf) - 1)) { - char c = Serial.read(); - if (c == '\n' || c == '\r') break; - cmdBuf[len++] = c; - } - cmdBuf[len] = '\0'; - if (len == 0) return; +void updateEye(bool force) { + unsigned long now = millis(); + unsigned long dtMs = (lastEyeFrameMs > 0) ? (now - lastEyeFrameMs) : EYE_FRAME_INTERVAL_MS; + if (!force && now - lastEyeFrameMs < EYE_FRAME_INTERVAL_MS) return; + lastEyeFrameMs = now; - // Convert to lowercase / 转换为小写 - for (int i = 0; cmdBuf[i]; i++) { - if (cmdBuf[i] >= 'A' && cmdBuf[i] <= 'Z') - cmdBuf[i] += ('a' - 'A'); + if (eye.trackEnabled && (now - eye.lastTrackInputMs > TRACK_HOLD_TIMEOUT_MS)) { + eye.targetX = 0.0f; + eye.targetY = 0.0f; } - // Split command and args / 分割命令和参数 - char* space = strchr(cmdBuf, ' '); - const char* args = ""; - if (space) { - *space = '\0'; - args = space + 1; - } + eye.gazeX += (eye.targetX - eye.gazeX) * 0.18f; + eye.gazeY += (eye.targetY - eye.gazeY) * 0.18f; - if (strcmp(cmdBuf, "danger") == 0) { - applyState("danger"); - } - else if (strcmp(cmdBuf, "relax") == 0) { - applyState("relax"); + if (eye.autoBlink && !eye.blinkRunning && now >= eye.nextBlinkMs) { + eye.blinkRunning = true; + eye.blinkStartMs = now; } - else if (strcmp(cmdBuf, "idle") == 0) { - applyState("idle"); - } - else if (strcmp(cmdBuf, "alert") == 0) { - applyState("alert"); - } - else if (strcmp(cmdBuf, "calm") == 0) { - applyState("calm"); - } - else if (strcmp(cmdBuf, "breathe") == 0) { - applyState("breathe"); + + float blinkOpen = 1.0f; + if (eye.blinkRunning) { + float p = (float)(now - eye.blinkStartMs) / (float)eye.blinkDurationMs; + if (p >= 1.0f) { + eye.blinkRunning = false; + eye.nextBlinkMs = now + chooseNextBlinkDelayMs(); + blinkOpen = 1.0f; + } else { + blinkOpen = 1.0f - sinf(p * PI); + blinkOpen = clampf(blinkOpen, 0.04f, 1.0f); + } } - else if (strcmp(cmdBuf, "angle") == 0) { - int a = atoi(args); - setTargetAngle(a); - Serial.printf("[Serial] Target angle: %d\n", a); + + float breatheScale = 1.0f; + float breatheDrift = 0.0f; + if (eye.autoBreathe) { + float phase = (float)now / 2400.0f; + breatheScale = 1.0f + 0.04f * sinf(phase * 2.0f * PI); + breatheDrift = 1.6f * sinf(phase * PI); } - else if (strcmp(cmdBuf, "speed") == 0) { - stepIntervalMs = constrain(atoi(args), 1, 200); - Serial.printf("[Serial] Step speed: %d ms/deg\n", stepIntervalMs); + + float openBase = eye.manualOpenOverride ? eye.manualOpen : 1.0f; + float openFactor = clampf(openBase * blinkOpen, 0.02f, 1.0f); + + if (eye.pupilAutoSpin) { + float spinSpeed = 0.65f + ((1.0f - openFactor) * 2.8f); + eye.pupilSpinPhase += spinSpeed * ((float)dtMs / 1000.0f); + if (eye.pupilSpinPhase > (2.0f * PI)) { + eye.pupilSpinPhase = fmodf(eye.pupilSpinPhase, 2.0f * PI); + } } - else if (strcmp(cmdBuf, "led") == 0) { - int r = 0, g = 0; - sscanf(args, "%d %d", &r, &g); - ledcWrite(RED_LED, constrain(r, 0, 255)); - ledcWrite(GREEN_LED, constrain(g, 0, 255)); - Serial.printf("[Serial] LED: R=%d G=%d\n", r, g); + + drawEyeFrame( + backCanvas, + eye.gazeX, + eye.gazeY, + openFactor, + breatheScale, + breatheDrift, + eye.pupilSpinPhase, + eye.pupilAutoSpin + ); + ((Arduino_Canvas*)backCanvas)->flush(); + Arduino_GFX* tmp = frontCanvas; + frontCanvas = backCanvas; + backCanvas = tmp; +} + +// ============================================================ +// OSC +// ============================================================ +void routeState(OSCMessage& msg, int addrOffset) { + if (msg.isString(0)) { + char s[24]; + msg.getString(0, s, sizeof(s)); + applyState(s); + } else if (msg.isInt(0)) { + int v = msg.getInt(0); + if (v == 0) applyState("rest"); + else if (v == 1) applyState("bloom"); + else if (v == 2) applyState("alert"); + else if (v == 3) applyState("soothe"); } - else if (strcmp(cmdBuf, "status") == 0) { - const char* stateNames[] = {"IDLE", "OPENING", "OPENED", "CLOSING"}; - Serial.printf("[Status] State: %s Angle: %d Target: %d Speed: %d ms/deg\n", - stateNames[currentState], currentAngle, targetAngle, stepIntervalMs); - Serial.printf("[Status] Network: %s IP: %s\n", - networkConnected ? "connected" : "disconnected", - USE_AP_MODE ? WiFi.softAPIP().toString().c_str() - : WiFi.localIP().toString().c_str()); +} + +void routeAngle(OSCMessage& msg, int addrOffset) { if (msg.isInt(0)) setPetalAngleSafe(msg.getInt(0), true); } +void routeOpen(OSCMessage& msg, int addrOffset) { if (msg.isInt(0)) setPetalOpenPercent(msg.getInt(0), true); } +void routeSpeed(OSCMessage& msg, int addrOffset) { if (msg.isInt(0)) petalStepIntervalMs = constrain(msg.getInt(0), 2, 120); } +void routeStop(OSCMessage& msg, int addrOffset) { emergencyStop(); } +void routeTrackAuto(OSCMessage& msg, int addrOffset) { if (msg.isInt(0)) eye.trackEnabled = msg.getInt(0) != 0; } +void routeTrackNorm(OSCMessage& msg, int addrOffset) { + float x = msg.isFloat(0) ? msg.getFloat(0) : (msg.isInt(0) ? (float)msg.getInt(0) : 0.5f); + float y = msg.isFloat(1) ? msg.getFloat(1) : (msg.isInt(1) ? (float)msg.getInt(1) : 0.5f); + setTrackNorm(x, y); +} +void routeTrackXY(OSCMessage& msg, int addrOffset) { + if (!msg.isInt(0) || !msg.isInt(1)) return; + int x = msg.getInt(0), y = msg.getInt(1); + int w = msg.isInt(2) ? msg.getInt(2) : 1920; + int h = msg.isInt(3) ? msg.getInt(3) : 1080; + setTrackPixel(x, y, w, h); +} +void routeTrackCenter(OSCMessage& msg, int addrOffset) { eye.targetX = 0.0f; eye.targetY = 0.0f; } +void routeEyeLook(OSCMessage& msg, int addrOffset) { + float x = msg.isFloat(0) ? msg.getFloat(0) : (msg.isInt(0) ? (float)msg.getInt(0) / 100.0f : 0.0f); + float y = msg.isFloat(1) ? msg.getFloat(1) : (msg.isInt(1) ? (float)msg.getInt(1) / 100.0f : 0.0f); + setManualEyeLook(x, y); +} +void routeEyeOpen(OSCMessage& msg, int addrOffset) { if (msg.isInt(0)) setManualEyeOpenPercent(msg.getInt(0)); } +void routeEyeBlink(OSCMessage& msg, int addrOffset) { if (msg.isInt(0)) eye.autoBlink = msg.getInt(0) != 0; } +void routeEyeBreathe(OSCMessage& msg, int addrOffset) { if (msg.isInt(0)) eye.autoBreathe = msg.getInt(0) != 0; } +void routeEyeLimits(OSCMessage& msg, int addrOffset) { + int lx = msg.isInt(0) ? msg.getInt(0) : 100; + int ly = msg.isInt(1) ? msg.getInt(1) : lx; + eye.gazeLimitX = clampf((float)constrain(lx, 10, 100) / 100.0f, 0.1f, 1.0f); + eye.gazeLimitY = clampf((float)constrain(ly, 10, 100) / 100.0f, 0.1f, 1.0f); +} +void routeEyePupilAuto(OSCMessage& msg, int addrOffset) { + if (msg.isInt(0)) eye.pupilAutoSpin = msg.getInt(0) != 0; +} + +void routeInfoSelf(OSCMessage& msg, int addrOffset) { + OSCMessage reply("/info/self"); + reply.add(NODE_ID); + uint8_t mac[6]; + WiFi.macAddress(mac); + char macStr[18]; + sprintf(macStr, "%02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + reply.add(macStr); + reply.add("STA"); + IPAddress ip = WiFi.localIP(); + char ipStr[16]; + sprintf(ipStr, "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); + reply.add(ipStr); + udp.beginPacket(udp.remoteIP(), udp.remotePort()); reply.send(udp); udp.endPacket(); reply.empty(); +} + +void routeInfoServo(OSCMessage& msg, int addrOffset) { + OSCMessage reply("/info/servo"); + reply.add((int32_t)currentPetalAngle); + reply.add((int32_t)targetPetalAngle); + reply.add((int32_t)petalStepIntervalMs); + reply.add((int32_t)(eye.trackEnabled ? 1 : 0)); + udp.beginPacket(udp.remoteIP(), udp.remotePort()); reply.send(udp); udp.endPacket(); reply.empty(); +} + +void processOSC() { + int size = udp.parsePacket(); + if (size <= 0) return; + OSCMessage msg; + while (size--) msg.fill(udp.read()); + if (msg.hasError()) return; + msg.route("/state", routeState); + msg.route("/angle", routeAngle); + msg.route("/open", routeOpen); + msg.route("/speed", routeSpeed); + msg.route("/stop", routeStop); + msg.route("/track/auto", routeTrackAuto); + msg.route("/track/mode", routeTrackAuto); + msg.route("/track/norm", routeTrackNorm); + msg.route("/track/xy", routeTrackXY); + msg.route("/track/center", routeTrackCenter); + msg.route("/eye/look", routeEyeLook); + msg.route("/eye/open", routeEyeOpen); + msg.route("/eye/blink", routeEyeBlink); + msg.route("/eye/breathe", routeEyeBreathe); + msg.route("/eye/limits", routeEyeLimits); + msg.route("/eye/pupil_auto", routeEyePupilAuto); + msg.route("/info/self", routeInfoSelf); + msg.route("/info/servo", routeInfoServo); +} + +// ============================================================ +// Serial +// ============================================================ +void parseSerialLine() { + if (!Serial.available()) return; + String line = Serial.readStringUntil('\n'); + line.trim(); + if (line.length() == 0) return; + + if (line == "help") { printHelp(); return; } + if (line == "info") { printSelfInfo(); return; } + if (line == "status") { + Serial.printf("[Status] angle=%d target=%d mode=%d eye=(%.2f,%.2f)\n", currentPetalAngle, targetPetalAngle, (int)petalMode, eye.gazeX, eye.gazeY); + return; } - else if (strcmp(cmdBuf, "stop") == 0) { - emergencyStop(); + if (line == "wifi status") { printWifiStatus(); return; } + if (line.startsWith("wifi retry")) { + int attempts = wifiManualRetryAttempts; + sscanf(line.c_str(), "wifi retry %d", &attempts); + manualWifiRetry(attempts); + return; } - else if (strcmp(cmdBuf, "help") == 0 || strcmp(cmdBuf, "?") == 0) { - printHelp(); + if (line.startsWith("state ")) { applyState(line.substring(6).c_str()); return; } + if (line.startsWith("angle ")) { setPetalAngleSafe(line.substring(6).toInt(), true); return; } + if (line.startsWith("open ")) { setPetalOpenPercent(line.substring(5).toInt(), true); return; } + if (line.startsWith("speed ")) { petalStepIntervalMs = constrain(line.substring(6).toInt(), 2, 120); return; } + if (line.startsWith("look ")) { + float x = 0, y = 0; + if (sscanf(line.c_str(), "look %f %f", &x, &y) == 2) setManualEyeLook(x, y); + return; } - else { - Serial.printf("[Serial] Unknown: '%s'. Type 'help'.\n", cmdBuf); + if (line.startsWith("eyeopen ")) { setManualEyeOpenPercent(line.substring(8).toInt()); return; } + if (line.startsWith("eyelimits ")) { + int lx = 100, ly = 100; + if (sscanf(line.c_str(), "eyelimits %d %d", &lx, &ly) >= 1) { + eye.gazeLimitX = clampf((float)constrain(lx, 10, 100) / 100.0f, 0.1f, 1.0f); + eye.gazeLimitY = clampf((float)constrain(ly, 10, 100) / 100.0f, 0.1f, 1.0f); + } + return; } + if (line == "pupilauto on") { eye.pupilAutoSpin = true; return; } + if (line == "pupilauto off") { eye.pupilAutoSpin = false; return; } + if (line == "track on") { eye.trackEnabled = true; return; } + if (line == "track off") { eye.trackEnabled = false; return; } +} + +void printSelfInfo() { + uint8_t mac[6]; + WiFi.macAddress(mac); + IPAddress ip = WiFi.localIP(); + Serial.println("\n=== Sue Node Info ==="); + Serial.printf("Node ID: %s\n", NODE_ID); + Serial.printf("Node Type: %s\n", NODE_TYPE); + Serial.printf("IP: %d.%d.%d.%d\n", ip[0], ip[1], ip[2], ip[3]); + Serial.printf("MAC: %02X:%02X:%02X:%02X:%02X:%02X\n", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + Serial.printf("Servo angle: %d (safe %d..%d)\n", currentPetalAngle, SERVO_SAFE_MIN_ANGLE, SERVO_SAFE_MAX_ANGLE); + Serial.printf( + "Eye track=%d blink=%d breathe=%d pupilAuto=%d limits=(%.2f,%.2f)\n", + eye.trackEnabled ? 1 : 0, + eye.autoBlink ? 1 : 0, + eye.autoBreathe ? 1 : 0, + eye.pupilAutoSpin ? 1 : 0, + eye.gazeLimitX, + eye.gazeLimitY + ); + Serial.println("=====================\n"); +} + +void printHelp() { + Serial.println("\n=== Sue Commands ==="); + Serial.println("state "); + Serial.println("open <0-100> | angle | speed <2-120>"); + Serial.println("look (range -1..1)"); + Serial.println("eyeopen <0-100>"); + Serial.println("eyelimits (10..100)"); + Serial.println("pupilauto on|off"); + Serial.println("track on|off"); + Serial.println("wifi status | wifi retry "); + Serial.println("status | info | help"); + Serial.println("====================\n"); } // ============================================================ -// Help Text / 帮助信息 +// Arduino setup / loop // ============================================================ -void printHelp() { - Serial.println("\n=== Sue Node Commands / 命令列表 ==="); - Serial.println("--- State presets / 状态预设 ---"); - Serial.println(" danger - Red LED + close / 红灯 + 闭合"); - Serial.println(" relax - Green LED + open / 绿灯 + 开放"); - Serial.println(" idle - All off + close / 全灭 + 闭合"); - Serial.println(" alert - Both LEDs + half-open / 双灯 + 半开"); - Serial.println(" calm - Green LED + slow open / 绿灯 + 慢开"); - Serial.println(" breathe - Open then auto-close 3s / 开后3秒自动闭"); - Serial.println("--- Fine control / 精细控制 ---"); - Serial.println(" angle [0-180] - Set target angle / 设置目标角度"); - Serial.println(" speed [1-200] - Step interval ms/deg / 步进间隔"); - Serial.println(" led [R] [G] - LED brightness (0-255) / LED 亮度"); - Serial.println("--- System ---"); - Serial.println(" status - Show current state / 显示当前状态"); - Serial.println(" stop - Emergency stop / 紧急停止"); - Serial.println(" help/? - This help / 帮助"); - Serial.println("--- OSC (via network) ---"); - Serial.println(" /state [danger|relax|idle|alert|calm|breathe]"); - Serial.println(" /state [0-5] - Same as above by number"); - Serial.println(" /angle [value] - Direct servo angle"); - Serial.println(" /speed [value] - Step speed ms/deg"); - Serial.println(" /led [r] [g] - LED control"); - Serial.println(" /stop - Emergency stop"); - Serial.println("==========================================\n"); +void setup() { + Serial.begin(115200); + Serial.setTimeout(20); + randomSeed((uint32_t)esp_random()); + Serial.println("\n========== DATT3700 Sue Node =========="); + + panel->begin(); + panel->setRotation(2); + eyeCanvasA->begin(); + eyeCanvasB->begin(); + drawStaticBackground(); + + petalServo.setPeriodHertz(50); + petalServo.attach(SERVO_PIN, SERVO_MIN_US, SERVO_MAX_US); + petalServo.write(PETAL_CLOSED_ANGLE); + currentPetalAngle = PETAL_CLOSED_ANGLE; + targetPetalAngle = PETAL_CLOSED_ANGLE; + + eye.nextBlinkMs = millis() + chooseNextBlinkDelayMs(); + + setupNetwork(); + setupMDNS(); + udp.begin(OSC_PORT); + Serial.printf("[OSC] Listening on %d\n", OSC_PORT); + + applyState("rest"); + updateEye(true); + printHelp(); +} + +void loop() { + processOSC(); + parseSerialLine(); + ensureWifiConnected(); + ensureMDNS(); + updatePetal(); + updateEye(false); } diff --git a/esp32_firmware_refactored/sylvie_client/sylvie_client.ino b/esp32_firmware_refactored/sylvie_client/sylvie_client.ino index 704d90b..5c0cedc 100644 --- a/esp32_firmware_refactored/sylvie_client/sylvie_client.ino +++ b/esp32_firmware_refactored/sylvie_client/sylvie_client.ino @@ -2,20 +2,11 @@ #include #include #include -#include -#include // ============================================================ // ⚙️ CONFIG — 所有可调参数都在这里修改 // ============================================================ -// --- 模式选择:true = 热点模式(AP),false = 连接已有WiFi(STA) --- -#define USE_AP_MODE false - -// --- 热点模式配置 --- -const char* AP_SSID = "F7OWER"; -const char* AP_PASSWORD = "12345678"; - // --- Station模式配置(连接已有WiFi)--- const char* STA_SSID = "MisAXNet"; const char* STA_PASSWORD = "AX6000@O26"; @@ -26,8 +17,11 @@ const char* MDNS_NAME = "sylvie"; // --- OSC 端口 --- const int OSC_PORT = 8888; -// --- 热点客户端扫描间隔(毫秒)--- -// const unsigned long CLIENT_SCAN_INTERVAL = 5000; +const int WIFI_BOOT_CONNECT_ATTEMPTS = 24; +const int WIFI_AUTO_RETRY_ATTEMPTS = 10; +const int WIFI_MANUAL_RETRY_DEFAULT = 6; +const int WIFI_RETRY_DELAY_MS = 500; +const unsigned long WIFI_RETRY_INTERVAL_MS = 6000; // --- 引脚定义 --- const int M1_A = 25, M1_B = 26; @@ -35,17 +29,6 @@ const int L1_R = 2, L1_G = 4, L1_B_PIN = 5; const int M2_A = 18, M2_B = 19; const int L2_R = 12, L2_G = 13, L2_B_PIN = 14; -// ============================================================ -// 客户端信息结构 -// ============================================================ -struct ClientInfo { - uint8_t mac[6]; - uint32_t ip; - bool active; -}; -#define MAX_CLIENTS 5 -ClientInfo clients[MAX_CLIENTS]; - // ============================================================ // 运行时变量 // ============================================================ @@ -54,6 +37,9 @@ bool autoMode = true; unsigned long lastAutoUpdate = 0; // unsigned long lastClientScan = 0; int autoState = 0; +unsigned long lastWifiRetryMs = 0; +int wifiManualRetryAttempts = WIFI_MANUAL_RETRY_DEFAULT; +bool mdnsStarted = false; // ── 前向声明 ──────────────────────────────────────────────── void setMotor(int motor, int direction); void setLED(int led, int r, int g, int b); @@ -68,85 +54,75 @@ void routeLED2(OSCMessage &msg, int addrOffset); void routePreset(OSCMessage &msg, int addrOffset); void printConnectedClients(); void printSelfInfo(); +void printWifiStatus(); +void manualWifiRetry(int attempts); +void ensureWifiConnected(); +void ensureMDNS(); void handleSerialCommand(); void sendClientListOSC(OSCMessage &msg, int addrOffset); void sendSelfInfoOSC(OSCMessage &msg, int addrOffset); // ──────────────────────────────────────────────────────────── // ============================================================ -// WiFi 事件处理 +// WiFi / mDNS // ============================================================ -void onWifiEvent(WiFiEvent_t event, WiFiEventInfo_t info) { - switch (event) { - - case ARDUINO_EVENT_WIFI_AP_STACONNECTED: - Serial.printf("\n🔌 客户端已连接 MAC: %02X:%02X:%02X:%02X:%02X:%02X(等待 DHCP 分配 IP...)\n", - info.wifi_ap_staconnected.mac[0], info.wifi_ap_staconnected.mac[1], - info.wifi_ap_staconnected.mac[2], info.wifi_ap_staconnected.mac[3], - info.wifi_ap_staconnected.mac[4], info.wifi_ap_staconnected.mac[5]); - break; - - case ARDUINO_EVENT_WIFI_AP_STAIPASSIGNED: - { - ip_event_ap_staipassigned_t* event_data = (ip_event_ap_staipassigned_t*)&info.wifi_ap_staipassigned; - uint32_t ip = event_data->ip.addr; - uint8_t* mac = event_data->mac; - Serial.printf("✅ IP 已分配 MAC: %02X:%02X:%02X:%02X:%02X:%02X IP: %d.%d.%d.%d\n", - mac[0], mac[1], mac[2], mac[3], mac[4], mac[5], - ip & 0xFF, (ip >> 8) & 0xFF, (ip >> 16) & 0xFF, (ip >> 24) & 0xFF); - for (int i = 0; i < MAX_CLIENTS; i++) { - if (!clients[i].active) { - memcpy(clients[i].mac, mac, 6); - clients[i].ip = ip; - clients[i].active = true; - break; - } - } - printConnectedClients(); +bool connectWifiWithAttempts(int attempts, bool verbose) { + attempts = constrain(attempts, 1, 120); + WiFi.mode(WIFI_STA); + WiFi.setSleep(false); + WiFi.setAutoReconnect(true); + WiFi.persistent(false); + WiFi.begin(STA_SSID, STA_PASSWORD); + + if (verbose) Serial.print("[Net] Connecting"); + for (int i = 0; i < attempts; i++) { + if (WiFi.status() == WL_CONNECTED) { + if (verbose) { + Serial.print("\n[Net] Connected, IP: "); + Serial.println(WiFi.localIP()); } - break; + return true; + } + delay(WIFI_RETRY_DELAY_MS); + if (verbose) Serial.print("."); + } - case ARDUINO_EVENT_WIFI_AP_STADISCONNECTED: - { - uint8_t* mac = info.wifi_ap_stadisconnected.mac; - Serial.printf("❌ 客户端已断开 MAC: %02X:%02X:%02X:%02X:%02X:%02X\n", - mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); - for (int i = 0; i < MAX_CLIENTS; i++) { - if (clients[i].active && memcmp(clients[i].mac, mac, 6) == 0) { - clients[i].active = false; - break; - } - } - printConnectedClients(); - } - break; + if (verbose) Serial.println("\n[Net] STA connect failed"); + return WiFi.status() == WL_CONNECTED; +} - default: break; +void setupNetwork() { + if (!connectWifiWithAttempts(WIFI_BOOT_CONNECT_ATTEMPTS, true)) { + Serial.println("[Net] Boot without WiFi, auto-retry enabled"); } } -// ============================================================ -// WiFi 初始化 -// ============================================================ -void setupWiFi() { - if (USE_AP_MODE) { - WiFi.mode(WIFI_AP); - WiFi.softAP(AP_SSID, AP_PASSWORD); - Serial.print("✅ AP模式已启动,IP: "); - Serial.println(WiFi.softAPIP()); - } else { - WiFi.mode(WIFI_STA); - WiFi.begin(STA_SSID, STA_PASSWORD); - Serial.print("🔗 连接WiFi中"); - int retry = 0; - while (WiFi.status() != WL_CONNECTED && retry < 20) { - delay(500); Serial.print("."); retry++; - } - if (WiFi.status() == WL_CONNECTED) { - Serial.print("\n✅ STA模式已连接,IP: "); - Serial.println(WiFi.localIP()); - } else { - Serial.println("\n❌ WiFi连接失败,请检查 STA_SSID / STA_PASSWORD"); - } +void ensureWifiConnected() { + if (WiFi.status() == WL_CONNECTED) return; + unsigned long now = millis(); + if (now - lastWifiRetryMs < WIFI_RETRY_INTERVAL_MS) return; + lastWifiRetryMs = now; + Serial.println("[Net] WiFi disconnected, retrying..."); + connectWifiWithAttempts(WIFI_AUTO_RETRY_ATTEMPTS, false); +} + +void setupMDNS() { + if (mdnsStarted) return; + if (WiFi.status() != WL_CONNECTED) return; + if (!MDNS.begin(MDNS_NAME)) { + Serial.println("[Net] mDNS failed"); + return; + } + MDNS.addService("osc", "udp", OSC_PORT); + MDNS.addService("datt_flower", "tcp", OSC_PORT); + MDNS.addServiceTxt("datt_flower", "tcp", "node_type", "sylvie"); + MDNS.addServiceTxt("datt_flower", "tcp", "node_id", MDNS_NAME); + mdnsStarted = true; + Serial.printf("[Net] mDNS ready: %s.local\n", MDNS_NAME); +} + +void ensureMDNS() { + if (!mdnsStarted && WiFi.status() == WL_CONNECTED) { + setupMDNS(); } } @@ -154,19 +130,7 @@ void setupWiFi() { // 打印当前在线客户端(AP 模式) // ============================================================ void printConnectedClients() { - if (!USE_AP_MODE) return; - int count = WiFi.softAPgetStationNum(); - Serial.printf("\n📡 当前在线客户端:%d\n", count); - for (int i = 0; i < MAX_CLIENTS; i++) { - if (clients[i].active) { - uint32_t ip = clients[i].ip; - Serial.printf(" [%d] MAC: %02X:%02X:%02X:%02X:%02X:%02X IP: %d.%d.%d.%d\n", - i, - clients[i].mac[0], clients[i].mac[1], clients[i].mac[2], - clients[i].mac[3], clients[i].mac[4], clients[i].mac[5], - ip & 0xFF, (ip >> 8) & 0xFF, (ip >> 16) & 0xFF, (ip >> 24) & 0xFF); - } - } + Serial.println("[Net] AP client list disabled in STA-only firmware"); } // ============================================================ @@ -181,25 +145,39 @@ void printSelfInfo() { Serial.printf("MAC: %02X:%02X:%02X:%02X:%02X:%02X\n", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); - if (USE_AP_MODE) { - Serial.printf("模式:AP (热点)\n"); + if (WiFi.status() == WL_CONNECTED) { + Serial.printf("模式:STA (客户端)\n"); Serial.printf("IP: %d.%d.%d.%d\n", - WiFi.softAPIP()[0], WiFi.softAPIP()[1], - WiFi.softAPIP()[2], WiFi.softAPIP()[3]); + WiFi.localIP()[0], WiFi.localIP()[1], + WiFi.localIP()[2], WiFi.localIP()[3]); } else { - if (WiFi.status() == WL_CONNECTED) { - Serial.printf("模式:STA (客户端)\n"); - Serial.printf("IP: %d.%d.%d.%d\n", - WiFi.localIP()[0], WiFi.localIP()[1], - WiFi.localIP()[2], WiFi.localIP()[3]); - } else { - Serial.println("模式:STA (未连接)"); - Serial.println("IP: 未分配"); - } + Serial.println("模式:STA (未连接)"); + Serial.println("IP: 未分配"); } Serial.println("====================\n"); } +void printWifiStatus() { + wl_status_t st = WiFi.status(); + Serial.println("\n=== WiFi Status ==="); + Serial.printf("SSID: %s\n", STA_SSID); + Serial.printf("Status: %d\n", (int)st); + if (st == WL_CONNECTED) { + Serial.printf("IP: %d.%d.%d.%d\n", WiFi.localIP()[0], WiFi.localIP()[1], WiFi.localIP()[2], WiFi.localIP()[3]); + Serial.printf("RSSI: %d dBm\n", WiFi.RSSI()); + } else { + Serial.println("IP: (not connected)"); + } + Serial.println("===================\n"); +} + +void manualWifiRetry(int attempts) { + wifiManualRetryAttempts = constrain(attempts, 1, 120); + Serial.printf("[Net] Manual retry, attempts=%d\n", wifiManualRetryAttempts); + bool ok = connectWifiWithAttempts(wifiManualRetryAttempts, true); + if (ok) ensureMDNS(); +} + // ============================================================ // 串口命令解析 // 支持格式:motor1 1 / led1 255 0 0 / auto 0 / preset 2 / clients / selfinfo @@ -234,13 +212,15 @@ void handleSerialCommand() { setPreset(p); Serial.printf("预设场景:%d\n", p); } else if (line.equals("clients")) { - if (USE_AP_MODE) { - printConnectedClients(); - } else { - Serial.println("⚠️ 仅在 AP 模式下有效"); - } + printConnectedClients(); } else if (line.equals("selfinfo")) { printSelfInfo(); + } else if (line.equals("wifi status")) { + printWifiStatus(); + } else if (line.startsWith("wifi retry")) { + int attempts = wifiManualRetryAttempts; + sscanf(line.c_str(), "wifi retry %d", &attempts); + manualWifiRetry(attempts); } } @@ -249,22 +229,16 @@ void handleSerialCommand() { // ============================================================ void setup() { Serial.begin(115200); - memset(clients, 0, sizeof(clients)); int pins[] = {M1_A, M1_B, M2_A, M2_B, L1_R, L1_G, L1_B_PIN, L2_R, L2_G, L2_B_PIN}; for (int p : pins) pinMode(p, OUTPUT); - WiFi.onEvent(onWifiEvent); - setupWiFi(); - - if (MDNS.begin(MDNS_NAME)) { - Serial.printf("✅ mDNS 已启动: http://%s.local\n", MDNS_NAME); - MDNS.addService("osc", "udp", OSC_PORT); - } + setupNetwork(); + setupMDNS(); udp.begin(OSC_PORT); Serial.printf("✅ OSC 监听端口: %d\n", OSC_PORT); - Serial.println("📋 串口命令: motor1 1 | motor2 -1 | led1 255 0 0 | led2 0 255 255 | auto 0 | preset 2"); + Serial.println("📋 串口命令: motor1 1 | motor2 -1 | led1 255 0 0 | led2 0 255 255 | auto 0 | preset 2 | wifi status | wifi retry 6"); } // ============================================================ @@ -288,6 +262,8 @@ void loop() { } handleSerialCommand(); + ensureWifiConnected(); + ensureMDNS(); if (autoMode) runAutoMode(); } @@ -425,28 +401,9 @@ void stopAll() { // OSC 信息查询命令 // ============================================================ void sendClientListOSC(OSCMessage &msg, int addrOffset) { - if (!USE_AP_MODE) return; - OSCMessage reply("/info/clients"); - int count = WiFi.softAPgetStationNum(); - reply.add((int32_t)count); - - for (int i = 0; i < MAX_CLIENTS; i++) { - if (clients[i].active) { - uint32_t ip = clients[i].ip; - char macStr[18]; - sprintf(macStr, "%02X:%02X:%02X:%02X:%02X:%02X", - clients[i].mac[0], clients[i].mac[1], clients[i].mac[2], - clients[i].mac[3], clients[i].mac[4], clients[i].mac[5]); - - char ipStr[16]; - sprintf(ipStr, "%d.%d.%d.%d", - ip & 0xFF, (ip >> 8) & 0xFF, (ip >> 16) & 0xFF, (ip >> 24) & 0xFF); - - reply.add(macStr); - reply.add(ipStr); - } - } + // STA-only firmware: keep endpoint for host compatibility. + reply.add((int32_t)0); udp.beginPacket(udp.remoteIP(), udp.remotePort()); reply.send(udp); @@ -467,12 +424,12 @@ void sendSelfInfoOSC(OSCMessage &msg, int addrOffset) { reply.add(macStr); char modeStr[10]; - strcpy(modeStr, USE_AP_MODE ? "AP" : "STA"); + strcpy(modeStr, "STA"); reply.add(modeStr); char ipStr[16]; - if (USE_AP_MODE || WiFi.status() == WL_CONNECTED) { - IPAddress ip = USE_AP_MODE ? WiFi.softAPIP() : WiFi.localIP(); + if (WiFi.status() == WL_CONNECTED) { + IPAddress ip = WiFi.localIP(); sprintf(ipStr, "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); } else { strcpy(ipStr, "0.0.0.0"); diff --git a/esp32_firmware_refactored/sylvie_main/sylvie_main.ino b/esp32_firmware_refactored/sylvie_main/sylvie_main.ino index 8ab477f..4d991b8 100644 --- a/esp32_firmware_refactored/sylvie_main/sylvie_main.ino +++ b/esp32_firmware_refactored/sylvie_main/sylvie_main.ino @@ -17,8 +17,8 @@ const char* AP_SSID = "F7OWER"; const char* AP_PASSWORD = "12345678"; // --- Station模式配置(连接已有WiFi)--- -const char* STA_SSID = "MisAXNet"; -const char* STA_PASSWORD = "AX6000@O26"; +const char* STA_SSID = "F7OWER"; +const char* STA_PASSWORD = "12345678"; // --- mDNS 设备广播名称(局域网内可用 sylvie.local 访问)--- const char* MDNS_NAME = "sylvie_1"; diff --git a/python_host/README.md b/python_host/README.md index 76db196..395c95d 100644 --- a/python_host/README.md +++ b/python_host/README.md @@ -25,6 +25,25 @@ python -m python_host.main --port 15000 Open `http://127.0.0.1:15000`. +## New machine ML bootstrap (ViT + DeepFace) + +Use this once per new device to download and cache ML model assets locally: + +```bash +python -m pip install -r python_host/requirements-ml.txt +python -m python_host.bootstrap_ml_models --verify-vit-local +``` + +What this does: +- Downloads `yst007/vit-emotion` into `python_host/models/vit-emotion` +- Warms up DeepFace emotion inference so model weights are cached +- Verifies ViT can load in local-only mode + +Recommended offline validation: +1. Disconnect network +2. Start the app: `python -m python_host.main --port 15000` +3. Confirm emotion inference still works for both ViT and DeepFace + ## Key API endpoints - `POST /api/devices/scan` with `{"mode":"mdns|gateway|auto"}` @@ -35,6 +54,7 @@ Open `http://127.0.0.1:15000`. - `POST /api/devices/select` - `POST /api/osc/raw` - `GET /api/osc/history` +- `POST /api/osc/history/clear` - `GET /api/serial/ports?scan=1` - `POST /api/serial/raw` - `GET|POST /api/tracking/config` diff --git a/python_host/bootstrap_ml_models.py b/python_host/bootstrap_ml_models.py new file mode 100644 index 0000000..c7b5c03 --- /dev/null +++ b/python_host/bootstrap_ml_models.py @@ -0,0 +1,121 @@ +"""Bootstrap ML model assets for first-time setup on a new machine. + +This script: +1) downloads ViT emotion model weights to python_host/models/vit-emotion +2) warms up DeepFace emotion analysis so model weights are cached locally +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +import numpy as np + + +def _default_vit_local_dir() -> Path: + here = Path(__file__).resolve().parent + return here / "models" / "vit-emotion" + + +def bootstrap_vit(repo_id: str, local_dir: Path) -> None: + try: + from huggingface_hub import snapshot_download + except ImportError as exc: + raise RuntimeError( + "huggingface_hub is not available. Install ML deps first: " + "python -m pip install -r python_host/requirements-ml.txt" + ) from exc + + local_dir.mkdir(parents=True, exist_ok=True) + print(f"[ViT] Downloading {repo_id} -> {local_dir}") + snapshot_download( + repo_id=repo_id, + local_dir=str(local_dir), + local_dir_use_symlinks=False, + ) + print("[ViT] Ready") + + +def warmup_deepface() -> None: + try: + from deepface import DeepFace + except ImportError as exc: + raise RuntimeError( + "DeepFace is not available. Install ML deps first: " + "python -m pip install -r python_host/requirements-ml.txt" + ) from exc + + # Dummy frame is enough to trigger weight loading/caching. + img = np.zeros((224, 224, 3), dtype=np.uint8) + print("[DeepFace] Running one warmup inference for emotion cache") + DeepFace.analyze(img, actions=["emotion"], enforce_detection=False, silent=True) + print("[DeepFace] Ready") + + +def verify_vit_local(local_dir: Path) -> None: + from python_host.vision.vit_emotion import ViTEmotionDetector + + prev_hf_offline = os.environ.get("HF_HUB_OFFLINE") + prev_tf_offline = os.environ.get("TRANSFORMERS_OFFLINE") + try: + os.environ["HF_HUB_OFFLINE"] = "1" + os.environ["TRANSFORMERS_OFFLINE"] = "1" + detector = ViTEmotionDetector(repo_id=str(local_dir)) + if not detector.load_model(): + raise RuntimeError("ViT local verification failed: detector.load_model() returned False") + finally: + if prev_hf_offline is None: + os.environ.pop("HF_HUB_OFFLINE", None) + else: + os.environ["HF_HUB_OFFLINE"] = prev_hf_offline + if prev_tf_offline is None: + os.environ.pop("TRANSFORMERS_OFFLINE", None) + else: + os.environ["TRANSFORMERS_OFFLINE"] = prev_tf_offline + + print("[Verify] ViT local-only load passed") + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description="Bootstrap local ML model caches") + p.add_argument("--repo-id", default="yst007/vit-emotion", help="Hugging Face model repo id") + p.add_argument( + "--vit-local-dir", + default=str(_default_vit_local_dir()), + help="Local directory for ViT model snapshot", + ) + p.add_argument("--skip-vit", action="store_true", help="Skip ViT snapshot download") + p.add_argument("--skip-deepface", action="store_true", help="Skip DeepFace warmup") + p.add_argument( + "--verify-vit-local", + action="store_true", + help="After download, verify ViT can load in local-only mode", + ) + return p + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + local_dir = Path(args.vit_local_dir).expanduser().resolve() + + if args.skip_vit and args.skip_deepface: + print("Nothing to do: both --skip-vit and --skip-deepface are enabled.") + return 0 + + if not args.skip_vit: + bootstrap_vit(args.repo_id, local_dir) + if args.verify_vit_local: + verify_vit_local(local_dir) + + if not args.skip_deepface: + warmup_deepface() + + print("Bootstrap complete.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/python_host/main.py b/python_host/main.py index 176fa01..aee4061 100644 --- a/python_host/main.py +++ b/python_host/main.py @@ -34,6 +34,7 @@ def main(): } ) app_module._selected_device = "sylvie_1" + app_module._set_control_mode(app_module.CONTROL_MODE_EMOTION_MANUAL, sync_target=False) app_module._set_camera_index(args.camera) # Auto-discover LAN devices once at startup for showcase flow. diff --git a/python_host_emo/models/vit-emotion/config.json b/python_host/models/vit-emotion/config.json similarity index 100% rename from python_host_emo/models/vit-emotion/config.json rename to python_host/models/vit-emotion/config.json diff --git a/python_host_emo/models/vit-emotion/preprocessor_config.json b/python_host/models/vit-emotion/preprocessor_config.json similarity index 100% rename from python_host_emo/models/vit-emotion/preprocessor_config.json rename to python_host/models/vit-emotion/preprocessor_config.json diff --git a/python_host/network/coordinate_publisher.py b/python_host/network/coordinate_publisher.py index d19cf71..d9eb84d 100644 --- a/python_host/network/coordinate_publisher.py +++ b/python_host/network/coordinate_publisher.py @@ -7,19 +7,22 @@ class CoordinatePublisher: - """Continuously publishes primary face coordinates using selected transport.""" + """Continuously publishes tracking coordinates using selected transport.""" def __init__( self, get_primary_target, get_selected_target, + get_selected_node_type, osc_sender, serial_sender=None, frame_width=1920, frame_height=1080, + invert_x=False, ): self._get_primary_target = get_primary_target self._get_selected_target = get_selected_target + self._get_selected_node_type = get_selected_node_type self._osc = osc_sender self._serial = serial_sender @@ -30,6 +33,7 @@ def __init__( self._transport = "osc" self._rate_hz = 20.0 self._deadband = 0.01 + self._invert_x = bool(invert_x) self._last_norm = None self._last_sent_ts = 0.0 @@ -49,6 +53,7 @@ def update_config( deadband=None, frame_width=None, frame_height=None, + invert_x=None, ): with self._lock: if enabled is not None: @@ -65,6 +70,8 @@ def update_config( self._frame_width = max(1, int(frame_width)) if frame_height is not None: self._frame_height = max(1, int(frame_height)) + if invert_x is not None: + self._invert_x = bool(invert_x) def snapshot(self) -> dict: with self._lock: @@ -75,6 +82,7 @@ def snapshot(self) -> dict: "deadband": self._deadband, "frame_width": self._frame_width, "frame_height": self._frame_height, + "invert_x": self._invert_x, "last_sent_ts": self._last_sent_ts, "last_result": self._last_result, } @@ -98,6 +106,7 @@ def _run(self): time.sleep(min(period, 0.2)) continue + # Target tuple may carry extra metadata (e.g. weighted total area). target = self._get_primary_target() if not target or len(target) < 2: with self._lock: @@ -107,6 +116,8 @@ def _run(self): nx = max(0.0, min(1.0, float(target[0]))) ny = max(0.0, min(1.0, float(target[1]))) + if cfg.get("invert_x"): + nx = 1.0 - nx if not self._should_send(nx, ny, cfg["deadband"]): time.sleep(period) continue @@ -116,13 +127,17 @@ def _run(self): if cfg["transport"] == "osc": target_name = self._get_selected_target() if target_name: - sent = self._osc.send_raw( - target_name, - "/track/norm", - [round(nx, 4), round(ny, 4)], - source="auto", - ) - result = "osc_ok" if sent else "osc_failed" + node_type = str(self._get_selected_node_type() or "unknown").lower().strip() + if node_type in ("sue", "face_track"): + sent = self._osc.send_raw( + target_name, + "/track/norm", + [round(nx, 4), round(ny, 4)], + source="auto", + ) + result = "osc_ok" if sent else "osc_failed" + else: + result = f"unsupported_node:{node_type}" else: result = "no_target" else: diff --git a/python_host/network/osc_sender.py b/python_host/network/osc_sender.py index c6d9cf8..3cdfb64 100644 --- a/python_host/network/osc_sender.py +++ b/python_host/network/osc_sender.py @@ -69,12 +69,13 @@ def _parse_osc_message(data): class OSCSender: """Manages one or more ESP32 OSC targets with a send queue.""" - def __init__(self): + def __init__(self, history_size=500): self._clients = {} # name -> SimpleUDPClient self._target_info = {} # name -> (ip, port) self._lock = threading.Lock() self._override = False # True = manual UI only, block CV auto - self._history = deque(maxlen=200) + size = max(50, int(history_size)) + self._history = deque(maxlen=size) # ------------------------------------------------------------------ # Target management @@ -106,21 +107,44 @@ def override(self): def override(self, value): self._override = bool(value) - def _push_history(self, direction, address, args, target_name=None, ip=None, port=None): - self._history.append( - { - "ts": time.time(), - "direction": direction, - "target": target_name, - "ip": ip, - "port": port, - "address": address, - "args": list(args or []), - } - ) + def _push_history( + self, + direction, + address, + args, + target_name=None, + ip=None, + port=None, + reason=None, + source=None, + ): + item = { + "ts": time.time(), + "direction": direction, + "target": target_name, + "ip": ip, + "port": port, + "address": address, + "args": list(args or []), + } + if reason is not None: + item["reason"] = str(reason) + if source is not None: + item["source"] = str(source) + with self._lock: + self._history.append(item) def get_history(self, limit=80): - return list(self._history)[-int(limit):] + n = max(1, int(limit)) + with self._lock: + return list(self._history)[-n:] + + def clear_history(self): + with self._lock: + self._history.clear() + + def get_history_capacity(self): + return int(self._history.maxlen or 0) # ------------------------------------------------------------------ # Send helpers @@ -133,16 +157,54 @@ def send(self, target_name, address, *args, source="auto"): source="manual" → always sent """ if source == "auto" and self._override: + self._push_history( + "drop", + address, + args, + target_name=target_name, + reason="override_blocked", + source=source, + ) return False # manual override active, ignore CV commands with self._lock: client = self._clients.get(target_name) target = self._target_info.get(target_name) if client is None: + self._push_history( + "drop", + address, + args, + target_name=target_name, + reason="unknown_target", + source=source, + ) + return False + try: + client.send_message(address, list(args)) + except OSError as exc: + ip, port = target if target else (None, None) + self._push_history( + "drop", + address, + args, + target_name=target_name, + ip=ip, + port=port, + reason=f"send_error:{exc}", + source=source, + ) return False - client.send_message(address, list(args)) ip, port = target if target else (None, None) - self._push_history("tx", address, args, target_name=target_name, ip=ip, port=port) + self._push_history( + "tx", + address, + args, + target_name=target_name, + ip=ip, + port=port, + source=source, + ) return True def send_raw(self, target_name, address, args=None, source="manual"): @@ -188,10 +250,10 @@ def _request_reply(self, ip, port, address, args=None, timeout=0.8): sock.settimeout(timeout) sock.bind(("0.0.0.0", 0)) sock.sendto(packet, (ip, int(port))) - self._push_history("tx", address, args or [], ip=ip, port=port) + self._push_history("tx", address, args or [], ip=ip, port=port, source="query") data, src = sock.recvfrom(2048) reply_addr, reply_args = _parse_osc_message(data) - self._push_history("rx", reply_addr, reply_args, ip=src[0], port=src[1]) + self._push_history("rx", reply_addr, reply_args, ip=src[0], port=src[1], source="query") return {"address": reply_addr, "args": reply_args, "ip": src[0], "port": src[1]} except OSError: return None diff --git a/python_host/tests/test_flask_app.py b/python_host/tests/test_flask_app.py index 73b5056..49836e8 100644 --- a/python_host/tests/test_flask_app.py +++ b/python_host/tests/test_flask_app.py @@ -235,6 +235,16 @@ def test_api_select_and_raw(self, client): assert history.status_code == 200 hist_data = json.loads(history.data) assert "items" in hist_data + assert "capacity" in hist_data + + cleared = client.post( + "/api/osc/history/clear", + data=json.dumps({}), + content_type="application/json", + ) + assert cleared.status_code == 200 + cleared_data = json.loads(cleared.data) + assert cleared_data["status"] == "ok" def test_api_camera_state(self, client): resp = client.get("/api/camera/state") diff --git a/python_host/tests/test_osc_sender.py b/python_host/tests/test_osc_sender.py index bda23a3..e5a29c8 100644 --- a/python_host/tests/test_osc_sender.py +++ b/python_host/tests/test_osc_sender.py @@ -32,6 +32,9 @@ def test_override_blocks_auto(self): sent = sender.send("test", "/motor1", 1, 128, source="auto") mock_client.send_message.assert_not_called() assert sent is False + history = sender.get_history(limit=1) + assert history[-1]["direction"] == "drop" + assert history[-1]["reason"] == "override_blocked" def test_override_allows_manual(self): sender = OSCSender() @@ -59,6 +62,9 @@ def test_send_to_nonexistent_target_silent(self): # Should not raise sent = sender.send("nonexistent", "/motor1", 1, 128, source="manual") assert sent is False + history = sender.get_history(limit=1) + assert history[-1]["direction"] == "drop" + assert history[-1]["reason"] == "unknown_target" def test_stop_all_ignores_override(self): sender = OSCSender() @@ -82,6 +88,19 @@ def test_send_raw_and_history(self): assert history assert history[-1]["address"] == "/state" assert history[-1]["args"] == ["relax"] + assert history[-1]["source"] == "manual" + + def test_clear_history(self): + sender = OSCSender() + mock_client = MagicMock() + sender._clients["test"] = mock_client + sender._target_info["test"] = ("127.0.0.1", 8888) + + sender.send_raw("test", "/state", ["relax"], source="manual") + assert sender.get_history(limit=5) + + sender.clear_history() + assert sender.get_history(limit=5) == [] def test_query_info_self_parsing(self): sender = OSCSender() diff --git a/python_host/ui/app.py b/python_host/ui/app.py index 6177779..484f785 100644 --- a/python_host/ui/app.py +++ b/python_host/ui/app.py @@ -53,6 +53,11 @@ _selected_device = None _known_device_names = set(registry.get("known_devices", {}).keys()) +CONTROL_MODE_TRACKING = "face_tracking" +CONTROL_MODE_EMOTION_MANUAL = "emotion_manual" +_control_mode_lock = threading.Lock() +_control_mode = CONTROL_MODE_TRACKING + def _jsonable(obj): """Convert numpy-heavy payloads into plain Python values for jsonify.""" @@ -75,10 +80,12 @@ def _jsonable(obj): return str(obj) tracking_publisher = CoordinatePublisher( - get_primary_target=lambda: tracker.get_primary_target(), + get_primary_target=lambda: tracker.get_tracking_target(), get_selected_target=lambda: _selected_target(), + get_selected_node_type=lambda: _selected_node_type(), osc_sender=osc, serial_sender=serial_sender, + invert_x=True, ) perception = PerceptionModule() @@ -161,6 +168,40 @@ def _emotion_target_devices(): ) +def _set_control_mode(mode: str, *, sync_target=True): + """Apply automation priority mode and keep target node state in sync.""" + global _control_mode + candidate = str(mode or "").strip().lower() + if candidate not in (CONTROL_MODE_TRACKING, CONTROL_MODE_EMOTION_MANUAL): + candidate = CONTROL_MODE_TRACKING + + with _control_mode_lock: + _control_mode = candidate + + if candidate == CONTROL_MODE_TRACKING: + tracking_publisher.update_config(enabled=True) + emotion_reactor.set_enabled(False) + if sync_target: + target = _selected_target() + if target: + osc.send_raw(target, "/track/mode", [1], source="manual") + osc.send_raw(target, "/track/auto", [1], source="manual") + else: + tracking_publisher.update_config(enabled=False) + emotion_reactor.set_enabled(True) + if sync_target: + target = _selected_target() + if target: + osc.send_raw(target, "/track/mode", [0], source="manual") + osc.send_raw(target, "/track/auto", [0], source="manual") + return _control_mode + + +def _get_control_mode(): + with _control_mode_lock: + return _control_mode + + def _safe_token(value, fallback): value = (value or "").strip() if not value: @@ -267,6 +308,7 @@ def api_faces(): { "camera_running": False, "primary": None, + "weighted": None, "faces": [], "perception": _jsonable(perception.get_results()), "reactor": emotion_reactor.snapshot(has_face=False), @@ -274,6 +316,7 @@ def api_faces(): ) target = tracker.get_primary_target() + weighted = tracker.get_weighted_target() faces = tracker.get_all_faces() has_face = bool(faces) perception_data = perception.get_results() @@ -282,6 +325,7 @@ def api_faces(): { "camera_running": True, "primary": _jsonable(target), + "weighted": _jsonable(weighted), "faces": _jsonable(faces), "perception": _jsonable(perception_data), "reactor": _jsonable(reactor), @@ -385,6 +429,13 @@ def api_devices_select(): if name not in _devices: return jsonify({"status": "error", "message": "device not found"}), 404 _selected_device = name + mode = _get_control_mode() + if mode == CONTROL_MODE_TRACKING: + osc.send_raw(_selected_device, "/track/mode", [1], source="manual") + osc.send_raw(_selected_device, "/track/auto", [1], source="manual") + else: + osc.send_raw(_selected_device, "/track/mode", [0], source="manual") + osc.send_raw(_selected_device, "/track/auto", [0], source="manual") return jsonify({"status": "ok", "selected": _selected_device}) @@ -506,8 +557,18 @@ def api_osc_raw(): @app.route("/api/osc/history") def api_osc_history(): - limit = int(request.args.get("limit", 80)) - return jsonify({"items": osc.get_history(limit=limit)}) + try: + limit = int(request.args.get("limit", 80)) + except (TypeError, ValueError): + limit = 80 + limit = max(1, min(limit, 2000)) + return jsonify({"items": osc.get_history(limit=limit), "limit": limit, "capacity": osc.get_history_capacity()}) + + +@app.route("/api/osc/history/clear", methods=["POST"]) +def api_osc_history_clear(): + osc.clear_history() + return jsonify({"status": "ok", "capacity": osc.get_history_capacity()}) @app.route("/api/osc/motor", methods=["POST"]) @@ -573,13 +634,16 @@ def api_tracking_config(): deadband=payload.get("deadband") if "deadband" in payload else None, frame_width=payload.get("frame_width") if "frame_width" in payload else None, frame_height=payload.get("frame_height") if "frame_height" in payload else None, + invert_x=payload.get("invert_x") if "invert_x" in payload else None, ) # Keep node-side auto mode aligned with panel toggle. if "enabled" in payload: target = _selected_target() if target: - osc.send_raw(target, "/track/auto", [1 if payload.get("enabled") else 0], source="manual") + flag = 1 if payload.get("enabled") else 0 + osc.send_raw(target, "/track/auto", [flag], source="manual") + osc.send_raw(target, "/track/mode", [flag], source="manual") serial_port = payload.get("serial_port") if "serial_port" in payload else None serial_baud = payload.get("serial_baud") if "serial_baud" in payload else None @@ -594,6 +658,7 @@ def api_tracking_config(): return jsonify( { "status": "ok", + "control_mode": _get_control_mode(), "tracking": tracking_publisher.snapshot(), "serial": serial_sender.status(), "selected_target": _selected_target(), @@ -601,6 +666,33 @@ def api_tracking_config(): ) +@app.route("/api/control/mode", methods=["GET", "POST"]) +def api_control_mode(): + if request.method == "POST": + payload = request.json or {} + mode = payload.get("mode", CONTROL_MODE_TRACKING) + applied = _set_control_mode(mode, sync_target=True) + return jsonify( + { + "status": "ok", + "mode": applied, + "tracking": tracking_publisher.snapshot(), + "reactor_enabled": emotion_reactor.is_enabled(), + "selected_target": _selected_target(), + } + ) + + return jsonify( + { + "status": "ok", + "mode": _get_control_mode(), + "tracking": tracking_publisher.snapshot(), + "reactor_enabled": emotion_reactor.is_enabled(), + "selected_target": _selected_target(), + } + ) + + @app.route("/api/reactor/config", methods=["GET", "POST"]) def api_reactor_config(): if request.method == "POST": @@ -758,8 +850,8 @@ def create_app(camera_index=0, esp32_targets=None): _camera_running = False perception.stop() emotion_reactor.reset() - emotion_reactor.set_enabled(True) - tracking_publisher.update_config(enabled=False, transport="osc") + tracking_publisher.update_config(enabled=False, transport="osc", invert_x=True) + _set_control_mode(CONTROL_MODE_EMOTION_MANUAL, sync_target=False) serial_sender.disconnect() if esp32_targets: for name, (ip, port) in esp32_targets.items(): @@ -771,4 +863,5 @@ def create_app(camera_index=0, esp32_targets=None): if __name__ == "__main__": _register_device({"name": "sylvie_1", "ip": "192.168.4.1", "port": 8888, "source": "default"}) _selected_device = "sylvie_1" + _set_control_mode(CONTROL_MODE_EMOTION_MANUAL, sync_target=False) app.run(host="0.0.0.0", port=5000, debug=False, threaded=True) diff --git a/python_host/ui/device_registry.json b/python_host/ui/device_registry.json index 2ed9a68..c8fb2b7 100644 --- a/python_host/ui/device_registry.json +++ b/python_host/ui/device_registry.json @@ -22,7 +22,7 @@ }, "sue": { "label": "Sue", - "description": "1x Servo, 2x mono LED channels" + "description": "1x servo petal + TFT eye (blink/breathe/track)" }, "face_track": { "label": "Face Tracking", diff --git a/python_host/ui/templates/index.html b/python_host/ui/templates/index.html index 31af73e..19158c1 100644 --- a/python_host/ui/templates/index.html +++ b/python_host/ui/templates/index.html @@ -27,6 +27,10 @@

Camera

Camera: off +
+ + Mirrors preview and flips tracking X publish +

Primary Face

Camera is off
@@ -46,7 +50,14 @@

Primary Face

Real-Time Emotion Analysis Table - model: - +
+ + model: - +
@@ -174,6 +185,17 @@

Raw OSC Console

+ +
+
+ +

@@ -191,20 +213,42 @@ 

Raw OSC Console

let recordedEvents = []; let loadedSequence = null; let trackingState = null; + let controlModeState = 'emotion_manual'; let sylvieManualArmed = false; let sylvieDrivePadState = { active: false, x: 0, y: 0 }; let lastMotorSendAt = { 1: 0, 2: 0 }; - let suePadState = { motionActive: false, ledActive: false }; - let lastSueSendAt = { motion: 0, led: 0 }; + let faceDrivePadState = { active: false, x: 0, y: 0 }; + let facePadInvertX = false; + let faceSelectedPairs = new Set([1]); + let facePairAngles = { + 1: { pan: 90, tilt: 90 }, + 2: { pan: 90, tilt: 90 }, + 3: { pan: 90, tilt: 90 }, + 4: { pan: 90, tilt: 90 }, + }; + let faceAngleLimits = { pan_min: 20, pan_max: 160, tilt_min: 20, tilt_max: 160 }; + let lastFacePairSendAt = 0; + let suePadState = { motionActive: false, eyeActive: false }; + let lastSueSendAt = { motion: 0, eye: 0 }; + let sueEyeConfig = { limitX: 100, limitY: 100, pupilAuto: true, invertX: false }; + let cameraMirrorEnabled = true; + let emotionModelSelection = 'auto'; let reactorConfigTimer = null; let reactorConfigLoaded = false; let showcaseMode = 'balanced'; + let oscHistoryLimit = 80; + let oscAutoRefreshEnabled = true; + let oscHistoryTimer = null; const SYLVIE_DEADBAND = 20; const SYLVIE_MIN_EFFECTIVE = 150; const SYLVIE_SEND_INTERVAL_MS = 40; + const FACE_PAIR_SEND_INTERVAL_MS = 55; const SUE_SEND_INTERVAL_MS = 50; - const SUE_ANGLE_MIN = 60; - const SUE_ANGLE_MAX = 120; + const SUE_EYE_SEND_INTERVAL_MS = 65; + const SUE_OPEN_MIN = 0; + const SUE_OPEN_MAX = 100; + const SUE_SPEED_MIN = 2; + const SUE_SPEED_MAX = 120; async function getJSON(url) { const r = await fetch(url); @@ -225,6 +269,7 @@

Raw OSC Console

return raw.split(',').map((item) => { const v = item.trim(); if (/^-?\d+$/.test(v)) return parseInt(v, 10); + if (/^-?\d+\.\d+$/.test(v)) return parseFloat(v); return v; }); } @@ -282,11 +327,34 @@

Raw OSC Console

}); } + function applyCameraMirrorUI() { + const img = document.getElementById('videoFeed'); + if (img) { + img.style.transform = cameraMirrorEnabled ? 'scaleX(-1)' : 'scaleX(1)'; + img.style.transformOrigin = 'center'; + } + const btn = document.getElementById('cameraMirrorToggle'); + if (btn) { + btn.textContent = `Preview Mirror: ${cameraMirrorEnabled ? 'ON' : 'OFF'}`; + btn.classList.toggle('bg-cyan-700', cameraMirrorEnabled); + btn.classList.toggle('bg-slate-700', !cameraMirrorEnabled); + } + } + + async function setCameraMirrorEnabled(enabled, syncTracking = true) { + cameraMirrorEnabled = !!enabled; + applyCameraMirrorUI(); + if (syncTracking) { + await postJSON('/api/tracking/config', { invert_x: cameraMirrorEnabled }); + } + } + async function refreshCameraState() { const s = await getJSON('/api/camera/state'); const running = !!s.running; document.getElementById('cameraStatus').textContent = `Camera: ${running ? 'on' : 'off'} (index ${s.index})`; document.getElementById('videoFeed').src = running ? `/video_feed?ts=${Date.now()}` : ''; + applyCameraMirrorUI(); if (!running) { document.getElementById('faceInfo').textContent = 'Camera is off'; renderReactorStatus(null, false); @@ -342,29 +410,41 @@

Raw OSC Console

let rows = []; let sourceModel = reactor && reactor.source_model ? reactor.source_model : '-'; - if (perception && perception.vit_emotion && Array.isArray(perception.vit_emotion.scores)) { + const wanted = emotionModelSelection || 'auto'; + const hasVit = !!(perception && perception.vit_emotion && Array.isArray(perception.vit_emotion.scores)); + const hasDeep = !!(perception && perception.emotion && perception.emotion.scores); + let useModel = wanted; + if (useModel === 'auto') { + useModel = hasVit ? 'vit' : (hasDeep ? 'deepface' : 'none'); + } + + if (useModel === 'vit' && hasVit) { const classes = Array.isArray(perception.vit_emotion.classes) ? perception.vit_emotion.classes : []; rows = perception.vit_emotion.scores.map((score, idx) => ({ emotion: classes[idx] || `e${idx}`, score: Number(score || 0), })); sourceModel = 'vit'; - } else if (perception && perception.emotion && perception.emotion.scores) { + } else if (useModel === 'deepface' && hasDeep) { rows = Object.entries(perception.emotion.scores).map(([emotion, score]) => ({ emotion, score: Number(score || 0) > 1 ? Number(score || 0) / 100.0 : Number(score || 0), })); sourceModel = 'deepface'; + } else if (useModel === 'vit' && !hasVit) { + sourceModel = 'vit(unavailable)'; + } else if (useModel === 'deepface' && !hasDeep) { + sourceModel = 'deepface(unavailable)'; } rows.sort((a, b) => b.score - a.score); if (!rows.length) { - modelTag.textContent = `model: ${sourceModel}`; + modelTag.textContent = `model: ${sourceModel} | selected: ${wanted}`; tbody.innerHTML = ''; return; } - modelTag.textContent = `model: ${sourceModel}`; + modelTag.textContent = `model: ${sourceModel} | selected: ${wanted}`; tbody.innerHTML = rows.map((item) => { const pct = Math.max(0, Math.min(100, item.score * 100)); return ` @@ -694,23 +774,27 @@

Raw OSC Console

} if (nodeType === 'face_track') { - const groups = [1, 2, 3, 4].map((i) => ` -
- - -
- `).join(''); - holder.innerHTML = ` -
+
-
Auto Tracking
+
Control Priority Mode
- - + + +
+
mode: -
+
+
+
Tracking Enable
+
+ +
+
+ +
Coordinate Transport
@@ -721,6 +805,41 @@

Raw OSC Console

+
+
Selected Servo Pairs (XY panel controls checked pairs)
+
+
+
+ +
+ + + + +
+
+ + + +
+
+
Face Tracking XY Panel (X: Pan, Y: Tilt)
+
+
+
+
+
+
+
+
X=0.00 Y=0.00 | Pan:90 Tilt:90 | pairs:[1]
@@ -740,9 +859,12 @@

Raw OSC Console

Tracking status loading...
- ${groups} `; + renderFacePairChecklist(); + syncFaceLimitInputsFromState(); + bindFaceLimitInputs(); + initFaceDrivePad(); refreshTrackingUI(); return; } @@ -750,21 +872,27 @@

Raw OSC Console

if (nodeType === 'sue') { holder.innerHTML = `
- - - - - - + + + + +
-
Angle
-
Speed
-
Angle: 90 | Speed: 20 ms/deg
+
+
Petal Open % and Motion Speed
+
Open + +
+
Speed + +
+
Open: 20% | Speed: 20 ms/deg
+
-
Motion Pad (X: angle ${SUE_ANGLE_MIN}..${SUE_ANGLE_MAX}, Y: speed 1..200)
+
Petal Pad (X: open ${SUE_OPEN_MIN}..${SUE_OPEN_MAX}%, Y: speed ${SUE_SPEED_MIN}..${SUE_SPEED_MAX})
@@ -775,21 +903,46 @@

Raw OSC Console

-
- - +
+
Eye Controls
+
+ + + + + + + +
+
Eye Open + +
+
Eye Open: 100%
-
R: 0 | G: 0
-
LED Pad (X: red 0..255, Y: green 0..255)
+
Eye Gaze Pad (X/Y -> /eye/look)
+
+ + +
+ + + +
+
+
Limit X: 100% | Limit Y: 100%
-
+
-
+
-
X=0.00 Y=0.00
+
X=0.00 Y=0.00
`; @@ -908,23 +1061,14 @@

Raw OSC Console

await sendRaw(`/led${id}`, [r, g, b]); } - async function sendSueLED(r = null, g = null, force = false) { - const rr = clamp(r === null ? parseInt(document.getElementById('sueR').value, 10) : parseInt(r, 10), 0, 255); - const gg = clamp(g === null ? parseInt(document.getElementById('sueG').value, 10) : parseInt(g, 10), 0, 255); - const now = Date.now(); - if (!force && now - lastSueSendAt.led < SUE_SEND_INTERVAL_MS) return; - lastSueSendAt.led = now; - await sendRaw('/led', [rr, gg]); + function updateSueOpenSpeedText(openPct, speed) { + const txt = document.getElementById('sueOpenSpeedText'); + if (txt) txt.textContent = `Open: ${openPct}% | Speed: ${speed} ms/deg`; } - function updateSueAngleSpeedText(angle, speed) { - const txt = document.getElementById('sueAngleSpeedText'); - if (txt) txt.textContent = `Angle: ${angle} | Speed: ${speed} ms/deg`; - } - - function updateSueLedText(r, g) { - const txt = document.getElementById('sueLedText'); - if (txt) txt.textContent = `R: ${r} | G: ${g}`; + function updateSueEyeOpenText(openPct) { + const txt = document.getElementById('sueEyeOpenText'); + if (txt) txt.textContent = `Eye Open: ${openPct}%`; } function setPadDot(dotId, x, y) { @@ -945,83 +1089,139 @@

Raw OSC Console

return Math.round(lo + t * (hi - lo)); } - function syncSueMotionPadDot(angle, speed) { - const nx = valueToNorm(angle, SUE_ANGLE_MIN, SUE_ANGLE_MAX); - const ny = valueToNorm(speed, 200, 1); // up = faster (smaller ms/deg) + function syncSueMotionPadDot(openPct, speed) { + const nx = valueToNorm(openPct, SUE_OPEN_MIN, SUE_OPEN_MAX); + const ny = valueToNorm(speed, SUE_SPEED_MAX, SUE_SPEED_MIN); // up = faster (smaller ms/deg) setPadDot('sueMotionDot', nx, ny); const txt = document.getElementById('sueMotionText'); if (txt) txt.textContent = `X=${nx.toFixed(2)} Y=${ny.toFixed(2)}`; } - function syncSueLedPadDot(r, g) { - const nx = valueToNorm(r, 0, 255); - const ny = valueToNorm(g, 0, 255); - setPadDot('sueLedDot', nx, ny); - const txt = document.getElementById('sueLedPadText'); - if (txt) txt.textContent = `X=${nx.toFixed(2)} Y=${ny.toFixed(2)}`; + function syncSueEyePadDot(gx, gy) { + const nx = clamp(gx, -1, 1); + const ny = clamp(gy, -1, 1); + setPadDot('sueEyeDot', nx, ny); + const txt = document.getElementById('sueEyePadText'); + if (txt) txt.textContent = `X=${nx.toFixed(2)} Y=${ny.toFixed(2)} | lim=(${sueEyeConfig.limitX}%,${sueEyeConfig.limitY}%) | invX:${sueEyeConfig.invertX ? 'on' : 'off'}`; + } + + function updateSueEyeLimitPreview() { + const lxEl = document.getElementById('sueEyeLimitX'); + const lyEl = document.getElementById('sueEyeLimitY'); + if (!lxEl || !lyEl) return; + const lx = clamp(parseInt(lxEl.value, 10) || 100, 10, 100); + const ly = clamp(parseInt(lyEl.value, 10) || 100, 10, 100); + const txt = document.getElementById('sueEyeLimitText'); + if (txt) txt.textContent = `Limit X: ${lx}% | Limit Y: ${ly}%`; + } + + async function applySueEyeLimits(forceSend = true) { + const lxEl = document.getElementById('sueEyeLimitX'); + const lyEl = document.getElementById('sueEyeLimitY'); + if (!lxEl || !lyEl) return; + const lx = clamp(parseInt(lxEl.value, 10) || 100, 10, 100); + const ly = clamp(parseInt(lyEl.value, 10) || 100, 10, 100); + sueEyeConfig.limitX = lx; + sueEyeConfig.limitY = ly; + updateSueEyeLimitPreview(); + if (forceSend) { + await sendRaw('/eye/limits', [lx, ly]); + } + } + + function renderSuePupilAutoButton() { + const btn = document.getElementById('suePupilAutoBtn'); + if (!btn) return; + btn.textContent = `Pupil Auto: ${sueEyeConfig.pupilAuto ? 'ON' : 'OFF'}`; + btn.classList.toggle('bg-cyan-700', sueEyeConfig.pupilAuto); + btn.classList.toggle('bg-slate-600', !sueEyeConfig.pupilAuto); + } + + function renderSueEyeInvertButton() { + const btn = document.getElementById('sueEyeInvertXBtn'); + if (!btn) return; + btn.textContent = `Gaze X Invert: ${sueEyeConfig.invertX ? 'ON' : 'OFF'}`; + btn.classList.toggle('bg-cyan-700', sueEyeConfig.invertX); + btn.classList.toggle('bg-slate-700', !sueEyeConfig.invertX); + } + + function toggleSueEyeInvertX() { + sueEyeConfig.invertX = !sueEyeConfig.invertX; + renderSueEyeInvertButton(); + syncSueEyePadDot(0, 0); + } + + async function toggleSuePupilAuto() { + sueEyeConfig.pupilAuto = !sueEyeConfig.pupilAuto; + renderSuePupilAutoButton(); + await sendRaw('/eye/pupil_auto', [sueEyeConfig.pupilAuto ? 1 : 0]); + } + + async function setSueTrackEnabled(enabled) { + await setTrackingEnabled(!!enabled); + await sendRaw('/track/auto', [enabled ? 1 : 0]); } - async function sendSueAngleSpeed(angle, speed, force = false) { - const a = clamp(parseInt(angle, 10) || 90, SUE_ANGLE_MIN, SUE_ANGLE_MAX); - const s = clamp(parseInt(speed, 10) || 20, 1, 200); + async function sendSueOpenSpeed(openPct, speed, force = false) { + const o = clamp(parseInt(openPct, 10) || 20, SUE_OPEN_MIN, SUE_OPEN_MAX); + const s = clamp(parseInt(speed, 10) || 20, SUE_SPEED_MIN, SUE_SPEED_MAX); const now = Date.now(); if (!force && now - lastSueSendAt.motion < SUE_SEND_INTERVAL_MS) return; lastSueSendAt.motion = now; await Promise.all([ - sendRaw('/angle', [a]), + sendRaw('/open', [o]), sendRaw('/speed', [s]), ]); } - async function sendSueAngleSpeedFromSliders(force = false) { - const angleEl = document.getElementById('sueAngle'); + async function sendSueOpenSpeedFromSliders(force = false) { + const openEl = document.getElementById('sueOpen'); const speedEl = document.getElementById('sueSpeed'); - if (!angleEl || !speedEl) return; - const angle = clamp(parseInt(angleEl.value, 10) || 90, SUE_ANGLE_MIN, SUE_ANGLE_MAX); - const speed = clamp(parseInt(speedEl.value, 10) || 20, 1, 200); - updateSueAngleSpeedText(angle, speed); - syncSueMotionPadDot(angle, speed); - await sendSueAngleSpeed(angle, speed, force); - } - - async function sendSueLEDFromSliders(force = false) { - const rEl = document.getElementById('sueR'); - const gEl = document.getElementById('sueG'); - if (!rEl || !gEl) return; - const r = clamp(parseInt(rEl.value, 10) || 0, 0, 255); - const g = clamp(parseInt(gEl.value, 10) || 0, 0, 255); - updateSueLedText(r, g); - syncSueLedPadDot(r, g); - await sendSueLED(r, g, force); + if (!openEl || !speedEl) return; + const openPct = clamp(parseInt(openEl.value, 10) || 20, SUE_OPEN_MIN, SUE_OPEN_MAX); + const speed = clamp(parseInt(speedEl.value, 10) || 20, SUE_SPEED_MIN, SUE_SPEED_MAX); + updateSueOpenSpeedText(openPct, speed); + syncSueMotionPadDot(openPct, speed); + await sendSueOpenSpeed(openPct, speed, force); + } + + async function sendSueEyeOpenFromSlider(force = false) { + const eyeOpenEl = document.getElementById('sueEyeOpen'); + if (!eyeOpenEl) return; + const eyeOpen = clamp(parseInt(eyeOpenEl.value, 10) || 100, 0, 100); + updateSueEyeOpenText(eyeOpen); + const now = Date.now(); + if (!force && now - lastSueSendAt.eye < SUE_EYE_SEND_INTERVAL_MS) return; + lastSueSendAt.eye = now; + await sendRaw('/eye/open', [eyeOpen]); } async function sendSueFromMotionPad(nx, ny, force = false) { - const angle = normToValue(nx, SUE_ANGLE_MIN, SUE_ANGLE_MAX); - const speed = normToValue(ny, 200, 1); - const angleEl = document.getElementById('sueAngle'); + const openPct = normToValue(nx, SUE_OPEN_MIN, SUE_OPEN_MAX); + const speed = normToValue(ny, SUE_SPEED_MAX, SUE_SPEED_MIN); + const openEl = document.getElementById('sueOpen'); const speedEl = document.getElementById('sueSpeed'); - if (angleEl) angleEl.value = `${angle}`; + if (openEl) openEl.value = `${openPct}`; if (speedEl) speedEl.value = `${speed}`; - updateSueAngleSpeedText(angle, speed); - syncSueMotionPadDot(angle, speed); - await sendSueAngleSpeed(angle, speed, force); + updateSueOpenSpeedText(openPct, speed); + syncSueMotionPadDot(openPct, speed); + await sendSueOpenSpeed(openPct, speed, force); } - async function sendSueFromLedPad(nx, ny, force = false) { - const r = normToValue(nx, 0, 255); - const g = normToValue(ny, 0, 255); - const rEl = document.getElementById('sueR'); - const gEl = document.getElementById('sueG'); - if (rEl) rEl.value = `${r}`; - if (gEl) gEl.value = `${g}`; - updateSueLedText(r, g); - syncSueLedPadDot(r, g); - await sendSueLED(r, g, force); + async function sendSueFromEyePad(nx, ny, force = false) { + syncSueEyePadDot(nx, ny); + const now = Date.now(); + if (!force && now - lastSueSendAt.eye < SUE_EYE_SEND_INTERVAL_MS) return; + lastSueSendAt.eye = now; + const xNorm = sueEyeConfig.invertX ? -nx : nx; + const x = Math.round(clamp(xNorm, -1, 1) * sueEyeConfig.limitX); + const y = Math.round(clamp(ny, -1, 1) * sueEyeConfig.limitY); + await sendRaw('/eye/look', [x, y]); } function initSuePads() { const motionPad = document.getElementById('sueMotionPad'); - const ledPad = document.getElementById('sueLedPad'); + const eyePad = document.getElementById('sueEyePad'); const pointerNorm = (pad, x, y) => { const rect = pad.getBoundingClientRect(); @@ -1045,41 +1245,43 @@

Raw OSC Console

const motionRelease = async () => { if (!suePadState.motionActive) return; suePadState.motionActive = false; - await sendSueAngleSpeedFromSliders(true); + await sendSueOpenSpeedFromSliders(true); }; motionPad.onpointerup = motionRelease; motionPad.onpointercancel = motionRelease; } - if (ledPad) { - ledPad.onpointerdown = async (ev) => { - suePadState.ledActive = true; - ledPad.setPointerCapture(ev.pointerId); - const { nx, ny } = pointerNorm(ledPad, ev.clientX, ev.clientY); - await sendSueFromLedPad(nx, ny, true); + if (eyePad) { + eyePad.onpointerdown = async (ev) => { + suePadState.eyeActive = true; + eyePad.setPointerCapture(ev.pointerId); + const { nx, ny } = pointerNorm(eyePad, ev.clientX, ev.clientY); + await sendSueFromEyePad(nx, ny, true); }; - ledPad.onpointermove = async (ev) => { - if (!suePadState.ledActive) return; - const { nx, ny } = pointerNorm(ledPad, ev.clientX, ev.clientY); - await sendSueFromLedPad(nx, ny, false); + eyePad.onpointermove = async (ev) => { + if (!suePadState.eyeActive) return; + const { nx, ny } = pointerNorm(eyePad, ev.clientX, ev.clientY); + await sendSueFromEyePad(nx, ny, false); }; - const ledRelease = async () => { - if (!suePadState.ledActive) return; - suePadState.ledActive = false; - await sendSueLEDFromSliders(true); + const eyeRelease = async () => { + if (!suePadState.eyeActive) return; + suePadState.eyeActive = false; + await sendSueFromEyePad(0, 0, true); }; - ledPad.onpointerup = ledRelease; - ledPad.onpointercancel = ledRelease; + eyePad.onpointerup = eyeRelease; + eyePad.onpointercancel = eyeRelease; } - sendSueAngleSpeedFromSliders(true); - sendSueLEDFromSliders(true); - } - - async function sendFacePair(idx) { - const pan = parseInt(document.getElementById(`pan${idx}`).value, 10); - const tilt = parseInt(document.getElementById(`tilt${idx}`).value, 10); - await sendRaw(`/flower${idx}`, [pan, tilt]); + sendSueOpenSpeedFromSliders(true); + sendSueEyeOpenFromSlider(true); + updateSueEyeLimitPreview(); + applySueEyeLimits(false); + renderSueEyeInvertButton(); + renderSuePupilAutoButton(); + if (selected) { + sendRaw('/eye/pupil_auto', [sueEyeConfig.pupilAuto ? 1 : 0]); + } + syncSueEyePadDot(0, 0); } async function scan(mode) { @@ -1103,14 +1305,56 @@

Raw OSC Console

} async function refreshHistory() { - const data = await getJSON('/api/osc/history?limit=30'); + const data = await getJSON(`/api/osc/history?limit=${oscHistoryLimit}`); const items = (data.items || []).map((item) => { const dt = new Date(item.ts * 1000).toLocaleTimeString(); - return `[${dt}] ${item.direction.toUpperCase()} ${item.address} ${JSON.stringify(item.args)} (${item.ip || item.target || '-'})`; + const dir = String(item.direction || '-').toUpperCase(); + const reason = item.reason ? ` reason=${item.reason}` : ''; + const source = item.source ? ` src=${item.source}` : ''; + return `[${dt}] ${dir} ${item.address} ${JSON.stringify(item.args)} (${item.ip || item.target || '-'})${source}${reason}`; }); document.getElementById('oscLog').textContent = items.join('\n'); } + function syncOscHistoryControls() { + const limitInput = document.getElementById('oscHistoryLimit'); + if (limitInput) { + limitInput.value = `${oscHistoryLimit}`; + } + const autoBox = document.getElementById('oscAutoRefresh'); + if (autoBox) { + autoBox.checked = !!oscAutoRefreshEnabled; + } + } + + async function applyOscHistorySettings() { + const limitInput = document.getElementById('oscHistoryLimit'); + const autoBox = document.getElementById('oscAutoRefresh'); + const rawLimit = limitInput ? parseInt(limitInput.value, 10) : oscHistoryLimit; + oscHistoryLimit = clamp(rawLimit || 80, 10, 500); + oscAutoRefreshEnabled = !!(autoBox ? autoBox.checked : oscAutoRefreshEnabled); + syncOscHistoryControls(); + startOscHistoryPolling(); + await refreshHistory(); + } + + function startOscHistoryPolling() { + if (oscHistoryTimer) { + clearInterval(oscHistoryTimer); + oscHistoryTimer = null; + } + if (!oscAutoRefreshEnabled) return; + oscHistoryTimer = setInterval(() => { + refreshHistory().catch(() => {}); + }, 1500); + } + + async function clearHistory() { + await postJSON('/api/osc/history/clear', {}); + document.getElementById('oscLog').textContent = ''; + await refreshHistory(); + } + async function addManualDevice() { const ip = (document.getElementById('gatewayIP').value || '').trim(); const port = parseInt(document.getElementById('gatewayPort').value, 10) || 8888; @@ -1137,9 +1381,311 @@

Raw OSC Console

async function getTrackingConfig() { trackingState = await getJSON('/api/tracking/config'); + if (trackingState && trackingState.control_mode) { + controlModeState = trackingState.control_mode; + } return trackingState; } + function selectedFacePairsSorted() { + return Array.from(faceSelectedPairs).map((v) => parseInt(v, 10)).filter((v) => v >= 1 && v <= 4).sort((a, b) => a - b); + } + + function sanitizeFaceLimits(raw) { + let panMin = clamp(parseInt(raw.pan_min, 10) || 20, 0, 180); + let panMax = clamp(parseInt(raw.pan_max, 10) || 160, 0, 180); + if (panMin > panMax) { + const t = panMin; + panMin = panMax; + panMax = t; + } + let tiltMin = clamp(parseInt(raw.tilt_min, 10) || 20, 0, 180); + let tiltMax = clamp(parseInt(raw.tilt_max, 10) || 160, 0, 180); + if (tiltMin > tiltMax) { + const t = tiltMin; + tiltMin = tiltMax; + tiltMax = t; + } + return { + pan_min: panMin, + pan_max: panMax, + tilt_min: tiltMin, + tilt_max: tiltMax, + }; + } + + function collectFaceLimitsFromUI() { + return sanitizeFaceLimits({ + pan_min: document.getElementById('facePanMin')?.value, + pan_max: document.getElementById('facePanMax')?.value, + tilt_min: document.getElementById('faceTiltMin')?.value, + tilt_max: document.getElementById('faceTiltMax')?.value, + }); + } + + function syncFaceLimitInputsFromState() { + const panMin = document.getElementById('facePanMin'); + const panMax = document.getElementById('facePanMax'); + const tiltMin = document.getElementById('faceTiltMin'); + const tiltMax = document.getElementById('faceTiltMax'); + if (panMin) panMin.value = `${faceAngleLimits.pan_min}`; + if (panMax) panMax.value = `${faceAngleLimits.pan_max}`; + if (tiltMin) tiltMin.value = `${faceAngleLimits.tilt_min}`; + if (tiltMax) tiltMax.value = `${faceAngleLimits.tilt_max}`; + } + + function bindFaceLimitInputs() { + ['facePanMin', 'facePanMax', 'faceTiltMin', 'faceTiltMax'].forEach((id) => { + const el = document.getElementById(id); + if (!el) return; + el.onchange = () => { + faceAngleLimits = collectFaceLimitsFromUI(); + syncFaceLimitInputsFromState(); + }; + }); + } + + function renderFacePairChecklist() { + const holder = document.getElementById('facePairChecklist'); + if (!holder) return; + holder.innerHTML = [1, 2, 3, 4].map((idx) => { + const checked = faceSelectedPairs.has(idx) ? 'checked' : ''; + return ``; + }).join(''); + holder.querySelectorAll('input[data-face-pair]').forEach((cb) => { + cb.onchange = () => { + const val = parseInt(cb.getAttribute('data-face-pair'), 10); + if (cb.checked) faceSelectedPairs.add(val); + else faceSelectedPairs.delete(val); + if (faceSelectedPairs.size === 0) faceSelectedPairs.add(1); + renderFacePairChecklist(); + updateFacePadDot(faceDrivePadState.x, faceDrivePadState.y); + }; + }); + } + + function faceNormToAngles(nx, ny) { + const limits = faceAngleLimits; + const xForMap = facePadInvertX ? -nx : nx; + // Match tracking mapping direction: rightward X mirrors to smaller pan. + const pan = normToValue(xForMap, limits.pan_max, limits.pan_min); + const tilt = normToValue(ny, limits.tilt_min, limits.tilt_max); + return { pan, tilt }; + } + + function renderFacePadInvertButton() { + const btn = document.getElementById('facePadInvertXBtn'); + if (!btn) return; + btn.textContent = `Face Pad X Invert: ${facePadInvertX ? 'ON' : 'OFF'}`; + btn.classList.toggle('bg-cyan-700', facePadInvertX); + btn.classList.toggle('bg-slate-700', !facePadInvertX); + } + + function toggleFacePadInvertX() { + facePadInvertX = !facePadInvertX; + renderFacePadInvertButton(); + updateFacePadDot(faceDrivePadState.x, faceDrivePadState.y); + } + + function updateFacePadDot(nx, ny, overrideAngles = null) { + const dot = document.getElementById('facePadDot'); + const txt = document.getElementById('facePadText'); + if (dot) { + dot.style.left = `${(nx + 1) * 50}%`; + dot.style.top = `${(1 - ny) * 50}%`; + } + const angles = overrideAngles || faceNormToAngles(nx, ny); + if (txt) { + const pairs = selectedFacePairsSorted(); + txt.textContent = `X=${nx.toFixed(2)} Y=${ny.toFixed(2)} | Pan:${angles.pan} Tilt:${angles.tilt} | pairs:[${pairs.join(',')}] | invX:${facePadInvertX ? 'on' : 'off'}`; + } + } + + async function sendFaceXYToPairs(nx, ny, force = false) { + const pairs = selectedFacePairsSorted(); + const angles = faceNormToAngles(nx, ny); + updateFacePadDot(nx, ny, angles); + if (!pairs.length) return; + + const now = Date.now(); + if (!force && now - lastFacePairSendAt < FACE_PAIR_SEND_INTERVAL_MS) return; + lastFacePairSendAt = now; + + pairs.forEach((idx) => { + facePairAngles[idx] = { pan: angles.pan, tilt: angles.tilt }; + }); + + if (!selected) return; + await Promise.all( + pairs.map((idx) => postJSON('/api/osc/raw', { + target: selected, + address: `/flower${idx}`, + args: [angles.pan, angles.tilt], + source: 'manual', + })) + ); + } + + async function centerFacePad() { + faceDrivePadState.x = 0; + faceDrivePadState.y = 0; + await sendFaceXYToPairs(0, 0, true); + } + + function initFaceDrivePad() { + const pad = document.getElementById('faceDrivePad'); + if (!pad) return; + + const pointerNorm = (x, y) => { + const rect = pad.getBoundingClientRect(); + const nx = clamp(((x - rect.left) / rect.width) * 2 - 1, -1, 1); + const ny = clamp(1 - ((y - rect.top) / rect.height) * 2, -1, 1); + return { nx, ny }; + }; + + const updateFromPointer = async (x, y, force = false) => { + const { nx, ny } = pointerNorm(x, y); + faceDrivePadState.x = nx; + faceDrivePadState.y = ny; + await sendFaceXYToPairs(nx, ny, force); + }; + + pad.onpointerdown = async (ev) => { + faceDrivePadState.active = true; + pad.setPointerCapture(ev.pointerId); + await setControlMode('emotion_manual', false); + await updateFromPointer(ev.clientX, ev.clientY, true); + }; + pad.onpointermove = async (ev) => { + if (!faceDrivePadState.active) return; + await updateFromPointer(ev.clientX, ev.clientY, false); + }; + const release = async () => { + if (!faceDrivePadState.active) return; + faceDrivePadState.active = false; + await sendFaceXYToPairs(faceDrivePadState.x, faceDrivePadState.y, true); + }; + pad.onpointerup = release; + pad.onpointercancel = release; + renderFacePadInvertButton(); + updateFacePadDot(faceDrivePadState.x, faceDrivePadState.y); + } + + async function setControlMode(mode, refreshUI = true) { + const payload = { mode }; + const resp = await postJSON('/api/control/mode', payload); + if (resp && resp.mode) controlModeState = resp.mode; + if (refreshUI) await refreshTrackingUI(); + return resp; + } + + function renderControlModeUI() { + const hint = document.getElementById('controlModeHint'); + const btnTrack = document.getElementById('modeFaceTrackingBtn'); + const btnEmotion = document.getElementById('modeEmotionManualBtn'); + if (hint) { + hint.textContent = controlModeState === 'face_tracking' + ? 'mode: Face tracking has automation priority' + : 'mode: Emotion + panel/manual has automation priority'; + } + if (btnTrack && btnEmotion) { + btnTrack.classList.toggle('ring-2', controlModeState === 'face_tracking'); + btnTrack.classList.toggle('ring-white', controlModeState === 'face_tracking'); + btnEmotion.classList.toggle('ring-2', controlModeState !== 'face_tracking'); + btnEmotion.classList.toggle('ring-white', controlModeState !== 'face_tracking'); + } + } + + async function setTrackingEnabled(enabled) { + await postJSON('/api/tracking/config', { enabled: !!enabled, invert_x: cameraMirrorEnabled }); + if (enabled) { + await setControlMode('face_tracking', false); + } + await refreshTrackingUI(); + } + + async function applyTrackingTransport() { + const transport = document.getElementById('trackTransport')?.value || 'osc'; + const serialPort = document.getElementById('serialPortSelect')?.value || ''; + const serialBaud = parseInt(document.getElementById('serialBaud')?.value || '115200', 10) || 115200; + await postJSON('/api/tracking/config', { + transport, + serial_port: serialPort, + serial_baud: serialBaud, + invert_x: cameraMirrorEnabled, + }); + await refreshTrackingUI(); + } + + async function refreshSerialPorts(scan = false) { + const data = await getJSON(`/api/serial/ports?scan=${scan ? 1 : 0}`); + const sel = document.getElementById('serialPortSelect'); + if (!sel) return data; + const ports = data.ports || []; + const current = (trackingState && trackingState.serial && trackingState.serial.port) ? trackingState.serial.port : ''; + sel.innerHTML = ''; + ports.forEach((item) => { + const opt = document.createElement('option'); + opt.value = item.device; + opt.textContent = `${item.device} ${item.description ? `(${item.description})` : ''}`; + if (item.device === current) opt.selected = true; + sel.appendChild(opt); + }); + return data; + } + + async function connectSerialNow() { + const serialPort = document.getElementById('serialPortSelect')?.value || ''; + const serialBaud = parseInt(document.getElementById('serialBaud')?.value || '115200', 10) || 115200; + await postJSON('/api/tracking/config', { + serial_port: serialPort, + serial_baud: serialBaud, + serial_connect: true, + }); + await refreshTrackingUI(); + } + + async function sendSerialDebugLine() { + const line = (document.getElementById('serialDebugLine')?.value || '').trim(); + if (!line) return; + await postJSON('/api/serial/raw', { line }); + await refreshTrackingUI(); + } + + async function applyFaceAngleLimits(pushToNode = true) { + faceAngleLimits = collectFaceLimitsFromUI(); + syncFaceLimitInputsFromState(); + if (pushToNode && selected) { + await postJSON('/api/osc/raw', { + target: selected, + address: '/track/limits', + args: [ + faceAngleLimits.pan_min, + faceAngleLimits.pan_max, + faceAngleLimits.tilt_min, + faceAngleLimits.tilt_max, + ], + source: 'manual', + }); + } + updateFacePadDot(faceDrivePadState.x, faceDrivePadState.y); + } + + async function refreshTrackingUI() { + await getTrackingConfig(); + const t = (trackingState && trackingState.tracking) ? trackingState.tracking : {}; + if (typeof t.invert_x === 'boolean') { + cameraMirrorEnabled = !!t.invert_x; + applyCameraMirrorUI(); + } + await refreshSerialPorts(false); + renderTrackingStatus(); + renderControlModeUI(); + } + function renderTrackingStatus() { const el = document.getElementById('trackStatus'); if (!el || !trackingState) return; @@ -1148,7 +1694,8 @@

Raw OSC Console

const s = trackingState.serial || {}; const targetText = trackingState.selected_target || '-'; const connected = s.connected ? 'connected' : 'disconnected'; - el.textContent = `auto:${t.enabled ? 'on' : 'off'} | transport:${t.transport || '-'} | target:${targetText} | serial:${connected} ${s.port || ''} | last:${t.last_result || '-'}`; + controlModeState = trackingState.control_mode || controlModeState; + el.textContent = `mode:${controlModeState} | auto:${t.enabled ? 'on' : 'off'} | transport:${t.transport || '-'} | invertX:${t.invert_x ? 'on' : 'off'} | target:${targetText} | serial:${connected} ${s.port || ''} | last:${t.last_result || '-'}`; const transportSel = document.getElementById('trackTransport'); if (transportSel && t.transport) transportSel.value = t.transport; @@ -1327,6 +1874,18 @@

Raw OSC Console

await sendRaw(address, args); await refreshHistory(); }; + document.getElementById('refreshHistory').onclick = async () => { + try { await refreshHistory(); } catch (e) { console.error(e); } + }; + document.getElementById('clearHistory').onclick = async () => { + try { await clearHistory(); } catch (e) { console.error(e); } + }; + document.getElementById('oscHistoryLimit').onchange = async () => { + try { await applyOscHistorySettings(); } catch (e) { console.error(e); } + }; + document.getElementById('oscAutoRefresh').onchange = async () => { + try { await applyOscHistorySettings(); } catch (e) { console.error(e); } + }; document.getElementById('seqRecordToggle').onclick = async () => { recording = !recording; @@ -1364,6 +1923,15 @@

Raw OSC Console

await refreshCameraState(); }; + document.getElementById('cameraMirrorToggle').onclick = async () => { + await setCameraMirrorEnabled(!cameraMirrorEnabled, true); + await refreshTrackingUI(); + }; + + document.getElementById('emotionModelSelect').onchange = (ev) => { + emotionModelSelection = String(ev.target.value || 'auto'); + }; + setInterval(async () => { try { const data = await getJSON('/api/faces'); @@ -1375,7 +1943,13 @@

Raw OSC Console

return; } if (data.primary) { - el.textContent = `X:${data.primary[0].toFixed(3)} Y:${data.primary[1].toFixed(3)} W:${data.primary[2].toFixed(1)}`; + const rawX = data.weighted ? Number(data.weighted[0]) : Number(data.primary[0]); + const showX = cameraMirrorEnabled ? (1.0 - rawX) : rawX; + const wx = showX.toFixed(3); + const wy = data.weighted ? Number(data.weighted[1]).toFixed(3) : Number(data.primary[1]).toFixed(3); + const wc = data.weighted ? Number(data.weighted[3] || 1) : 1; + const wa = data.weighted ? Number(data.weighted[2] || 0).toFixed(1) : Number(data.primary[2] || 0).toFixed(1); + el.textContent = `Track X:${wx} Y:${wy} | faces:${wc} | area:${wa}`; } else { el.textContent = 'No face detected'; } @@ -1390,12 +1964,15 @@

Raw OSC Console

bindShowcaseModeButtons(); await loadReactorConfig(); await loadEmotionOverrideState(); + applyCameraMirrorUI(); await refreshCameraState(); await refreshDevices(); renderTabs(); await loadSequences(); + syncOscHistoryControls(); + startOscHistoryPolling(); await refreshHistory(); - await getTrackingConfig(); + await refreshTrackingUI(); } catch (err) { console.error('init failed', err); document.getElementById('connStatus').textContent = `Init error: ${err.message || err}`; diff --git a/python_host/vision/emotion_reactor.py b/python_host/vision/emotion_reactor.py index 6b19ce4..c1bd722 100644 --- a/python_host/vision/emotion_reactor.py +++ b/python_host/vision/emotion_reactor.py @@ -412,13 +412,13 @@ def _command_for(self, node_type, flower_emotion): node = str(node_type or "").lower() if node == "sue": - mapping = { - "BLOOM": ("/state", ["relax"]), - "ALERT": ("/state", ["danger"]), - "SOOTHE": ("/state", ["calm"]), - "REST": ("/state", ["idle"]), + options = { + "BLOOM": [("/state", ["bloom"]), ("/state", ["relax"])], + "ALERT": [("/state", ["alert"]), ("/state", ["danger"])], + "SOOTHE": [("/state", ["soothe"]), ("/state", ["calm"])], + "REST": [("/state", ["rest"]), ("/state", ["idle"])], } - return mapping.get(flower_emotion) + return self._next_option(node, flower_emotion, options) if node == "kait": options = { diff --git a/python_host/vision/face_tracker.py b/python_host/vision/face_tracker.py index 84a1b68..fed5e85 100644 --- a/python_host/vision/face_tracker.py +++ b/python_host/vision/face_tracker.py @@ -1,15 +1,12 @@ """ face_tracker.py — Weighted multi-face tracking with multi-camera support. -Selects the primary target using: - weight = bbox_area × (1 / (1 + center_distance)) - -Outputs only the primary target's normalized coordinates (0.0-1.0). +Primary target: largest face by pixel area. +Tracking target: area-weighted centroid of all detected faces. No heavy ML dependencies — uses only OpenCV Haar Cascade. """ import cv2 -import math import threading import time import sys @@ -30,7 +27,8 @@ def __init__(self, camera_index=0, frame_width=1280, frame_height=720): self._lock = threading.Lock() self._latest_frame = None - self._primary_target = None # (norm_x, norm_y, weight) + self._primary_target = None # (norm_x, norm_y, area) + self._weighted_target = None # (norm_x, norm_y, total_area, face_count) self._all_faces = [] self._running = False @@ -68,6 +66,18 @@ def get_primary_target(self): with self._lock: return self._primary_target + def get_weighted_target(self): + """Return (norm_x, norm_y, total_area, face_count) for multi-face tracking.""" + with self._lock: + return self._weighted_target + + def get_tracking_target(self): + """Preferred target for control pipeline.""" + with self._lock: + if self._weighted_target is not None: + return self._weighted_target + return self._primary_target + def get_all_faces(self): """Return list of face dicts for overlay rendering.""" with self._lock: @@ -96,8 +106,6 @@ def _capture_loop(self): def _process_frame(self, frame): h, w = frame.shape[:2] - cx_frame, cy_frame = w / 2.0, h / 2.0 - max_dist = math.hypot(cx_frame, cy_frame) gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) rects = self._cascade.detectMultiScale( @@ -105,16 +113,16 @@ def _process_frame(self, frame): ) faces = [] - best_weight = -1.0 + best_area = -1.0 best_target = None + weighted_sum_x = 0.0 + weighted_sum_y = 0.0 + total_area = 0.0 for x, y, fw, fh in rects: area = fw * fh cx_face = x + fw / 2.0 cy_face = y + fh / 2.0 - dist = math.hypot(cx_face - cx_frame, cy_face - cy_frame) - proximity = 1.0 / (1.0 + dist / max_dist) - weight = area * proximity norm_x = cx_face / w norm_y = cy_face / h @@ -123,20 +131,23 @@ def _process_frame(self, frame): "x": int(x), "y": int(y), "w": int(fw), "h": int(fh), "norm_x": round(norm_x, 4), "norm_y": round(norm_y, 4), - "weight": round(weight, 2), + "area": int(area), } faces.append(face_info) + weighted_sum_x += norm_x * area + weighted_sum_y += norm_y * area + total_area += area - if weight > best_weight: - best_weight = weight - best_target = (round(norm_x, 4), round(norm_y, 4), round(weight, 2)) + if area > best_area: + best_area = area + best_target = (round(norm_x, 4), round(norm_y, 4), int(area)) # Draw bounding box on frame for preview cv2.rectangle(frame, (x, y), (x + fw, y + fh), (0, 255, 0), 2) # Highlight primary target if best_target and faces: - primary = max(faces, key=lambda f: f["weight"]) + primary = max(faces, key=lambda f: f["area"]) cv2.rectangle( frame, (primary["x"], primary["y"]), @@ -144,10 +155,24 @@ def _process_frame(self, frame): (0, 0, 255), 3, ) + weighted_target = None + if total_area > 0: + wx = weighted_sum_x / total_area + wy = weighted_sum_y / total_area + weighted_target = ( + round(wx, 4), + round(wy, 4), + round(total_area, 1), + len(faces), + ) + # Blue dot = area-weighted centroid used for tracking output. + cv2.circle(frame, (int(wx * w), int(wy * h)), 7, (255, 140, 0), -1) + with self._lock: self._latest_frame = frame self._all_faces = faces self._primary_target = best_target + self._weighted_target = weighted_target @staticmethod def list_cameras(max_check=2): diff --git a/python_host/vision/perception.py b/python_host/vision/perception.py index 0f51685..ac88de6 100644 --- a/python_host/vision/perception.py +++ b/python_host/vision/perception.py @@ -209,7 +209,7 @@ def _loop(self): faces = self._tracker.get_all_faces() face_bbox = None if faces: - primary = max(faces, key=lambda f: f["weight"]) + primary = max(faces, key=lambda f: f.get("area", 0)) face_bbox = (primary["x"], primary["y"], primary["w"], primary["h"]) vit_result = self._vit_detector.predict(frame, face_bbox) diff --git a/python_host/vision/vit_emotion.py b/python_host/vision/vit_emotion.py index e56ab81..f03ac1c 100644 --- a/python_host/vision/vit_emotion.py +++ b/python_host/vision/vit_emotion.py @@ -57,14 +57,32 @@ def load_model(self): model_source, local_only = self._resolve_model_source() logger.info("Loading ViT model from %s (local_only=%s)", model_source, local_only) - self._model = ViTForImageClassification.from_pretrained( - model_source, - local_files_only=local_only, - ).to(self._device) - self._processor = ViTImageProcessor.from_pretrained( - model_source, - local_files_only=local_only, - ) + def _load_pair(source, only_local): + model = ViTForImageClassification.from_pretrained( + source, + local_files_only=only_local, + ).to(self._device) + processor = ViTImageProcessor.from_pretrained( + source, + local_files_only=only_local, + ) + return model, processor + + try: + self._model, self._processor = _load_pair(model_source, local_only) + except Exception as local_exc: + # Local model directory exists but is incomplete/corrupt: + # fallback to remote repo so first-run bootstrap can recover. + if local_only and model_source != self._repo_id: + logger.warning( + "Local ViT model load failed (%s). Falling back to repo %s", + local_exc, + self._repo_id, + ) + self._model, self._processor = _load_pair(self._repo_id, False) + else: + raise + self._model.eval() self._loaded = True
No emotion scores yet