#include "HomeSpan.h" #include #include #include // ======================================================= // CONFIGURACIÓN DEL DISPOSITIVO // ======================================================= // Cambia estos datos antes de cargar el código. // Si tienes varias persianas, cada una debe tener // un nombre y un número de serie diferentes. const char* DEVICE_NAME = "NOMBRE_DE_TU_PERSIANA"; const char* DEVICE_SERIAL = "PERSIANA-001"; const char* DEVICE_MANUFACTURER = "TU_NOMBRE_O_MARCA"; const char* DEVICE_MODEL = "ESP32 HomeKit Blind"; const char* DEVICE_FIRMWARE = "1.9"; // Código HomeKit de 8 dígitos. // Sustituye XXXXXXXX por un código propio antes de cargar el firmware. const char* PAIRING_CODE = "XXXXXXXX"; // ======================================================= // PINES // ======================================================= // BTS7960 #define R_EN 26 #define L_EN 27 #define RPWM 18 #define LPWM 19 // Final de carrera superior #define LIMIT_TOP 23 // ======================================================= // PARÁMETROS // ======================================================= // Estos valores se utilizan inicialmente. // Después pueden cambiarse desde la página web // y quedan guardados en la memoria del ESP32. int speedUpPercent = 90; int speedDownPercent = 30; // Tiempo que tarda aproximadamente la persiana // en bajar desde 100% hasta 0%. unsigned long downTravelTime = 5000; // Tiempo máximo durante el que se permite subir // sin detectar el final de carrera. unsigned long maxOpenTime = 20000; const int MIN_SPEED = 25; const int MAX_SPEED = 100; // El final debe permanecer activo durante este tiempo // para considerarse una detección válida. const unsigned long LIMIT_DEBOUNCE_MS = 80; // Si el ESP32 permanece este tiempo sin WiFi, // se reinicia automáticamente. const unsigned long WIFI_RESTART_TIMEOUT_MS = 90000; // ======================================================= // WEB Y MEMORIA // ======================================================= WebServer server(8080); Preferences prefs; bool webStarted = false; bool possibleJam = false; // ======================================================= // SUPERVISIÓN WIFI // ======================================================= unsigned long wifiDisconnectedSince = 0; bool wifiTimerStarted = false; // HomeSpan utilizará esta función cuando necesite // iniciar una conexión WiFi. void customWifiBegin(const char* ssid, const char* password) { Serial.println(); Serial.println("Iniciando conexion WiFi controlada..."); WiFi.setAutoReconnect(false); // Cancelamos cualquier intento anterior de conexión // antes de empezar uno nuevo. WiFi.disconnect(false, false); delay(150); WiFi.mode(WIFI_STA); delay(50); WiFi.begin(ssid, password); } // Si la conexión WiFi queda bloqueada durante demasiado // tiempo, el ESP32 se reinicia automáticamente. void updateWifiWatchdog() { if (WiFi.status() == WL_CONNECTED) { wifiTimerStarted = false; return; } if (!wifiTimerStarted) { wifiDisconnectedSince = millis(); wifiTimerStarted = true; Serial.println( "WiFi no conectado. Iniciado temporizador de recuperacion." ); } if ( millis() - wifiDisconnectedSince >= WIFI_RESTART_TIMEOUT_MS ) { Serial.println(); Serial.println("********************************"); Serial.println("WiFi sin conexion durante 90 s."); Serial.println("Reiniciando ESP32 automaticamente..."); Serial.println("********************************"); delay(300); ESP.restart(); } } // ======================================================= // FINAL DE CARRERA // ======================================================= bool limitRawState = false; bool limitStableState = false; unsigned long limitRawChangedAt = 0; // El sensor está conectado entre GPIO23 y GND. // Con INPUT_PULLUP: // LOW = imán detectado // HIGH = final no activo bool readLimitRaw() { return digitalRead(LIMIT_TOP) == LOW; } // Lee el sensor al arrancar y espera a que su estado // permanezca estable. void initLimitSwitch() { bool candidate = readLimitRaw(); unsigned long stableSince = millis(); while ( millis() - stableSince < LIMIT_DEBOUNCE_MS ) { bool currentReading = readLimitRaw(); if (currentReading != candidate) { candidate = currentReading; stableSince = millis(); } delay(1); } limitRawState = candidate; limitStableState = candidate; limitRawChangedAt = millis(); Serial.print("Final de carrera al arrancar: "); Serial.println( limitStableState ? "ACTIVO" : "NO ACTIVO" ); } // Filtra pequeños pulsos o interferencias. // El final solo cambia de estado si permanece estable // durante LIMIT_DEBOUNCE_MS. void updateLimitSwitch() { bool raw = readLimitRaw(); if (raw != limitRawState) { limitRawState = raw; limitRawChangedAt = millis(); } if ( raw != limitStableState && millis() - limitRawChangedAt >= LIMIT_DEBOUNCE_MS ) { limitStableState = raw; Serial.print("Final de carrera estable: "); Serial.println( limitStableState ? "ACTIVO" : "NO ACTIVO" ); } } bool limitTopActive() { return limitStableState; } // ======================================================= // ESTADO DE MOVIMIENTO // ======================================================= enum MotionPhase { IDLE, OPENING_TO_LIMIT, CLOSING_BY_TIME }; MotionPhase phase = IDLE; // Convención: // // 0% = completamente bajada // 100% = completamente subida // // Al arrancar, si no se conoce la posición real, // se utiliza 20% como posición inicial segura. int currentPercent = 20; int targetPercent = 20; int finalRequestedTarget = 20; int movementStartPercent = 20; unsigned long movementStartTime = 0; unsigned long movementDuration = 0; // ======================================================= // HOMEKIT // ======================================================= struct BlindHomeKit; BlindHomeKit* blind = nullptr; // ======================================================= // MOTOR // ======================================================= int percentToPWM(int percent) { percent = constrain( percent, MIN_SPEED, MAX_SPEED ); return map( percent, 0, 100, 0, 255 ); } // SUBIR void motorOpen() { int pwm = percentToPWM( speedUpPercent ); digitalWrite(R_EN, HIGH); digitalWrite(L_EN, HIGH); analogWrite(RPWM, 0); analogWrite(LPWM, pwm); } // BAJAR void motorClose() { int pwm = percentToPWM( speedDownPercent ); digitalWrite(R_EN, HIGH); digitalWrite(L_EN, HIGH); analogWrite(RPWM, pwm); analogWrite(LPWM, 0); } // PARAR void motorStop() { analogWrite(RPWM, 0); analogWrite(LPWM, 0); digitalWrite(R_EN, LOW); digitalWrite(L_EN, LOW); } // ======================================================= // AJUSTES GUARDADOS // ======================================================= void loadSettings() { prefs.begin( "blind", false ); speedUpPercent = prefs.getInt( "speedUp", 90 ); speedDownPercent = prefs.getInt( "speedDown", 30 ); downTravelTime = prefs.getULong( "downTime", 5000 ); maxOpenTime = prefs.getULong( "maxOpen", 20000 ); possibleJam = prefs.getBool( "jam", false ); speedUpPercent = constrain( speedUpPercent, MIN_SPEED, MAX_SPEED ); speedDownPercent = constrain( speedDownPercent, MIN_SPEED, MAX_SPEED ); if ( downTravelTime < 1000 || downTravelTime > 120000 ) { downTravelTime = 5000; } if ( maxOpenTime < 1000 || maxOpenTime > 120000 ) { maxOpenTime = 20000; } } void saveSettings() { prefs.putInt( "speedUp", speedUpPercent ); prefs.putInt( "speedDown", speedDownPercent ); prefs.putULong( "downTime", downTravelTime ); prefs.putULong( "maxOpen", maxOpenTime ); } // ======================================================= // DECLARACIONES // ======================================================= void stopAll(); void requestTarget(int target); void updateMovement(); void setPossibleJam(bool state); // ======================================================= // SERVICIO HOMEKIT // ======================================================= struct BlindHomeKit : Service::WindowCovering { SpanCharacteristic* currentPosition; SpanCharacteristic* targetPosition; SpanCharacteristic* positionState; SpanCharacteristic* obstructionDetected; BlindHomeKit() : Service::WindowCovering() { currentPosition = new Characteristic::CurrentPosition( currentPercent ); targetPosition = new Characteristic::TargetPosition( targetPercent ); positionState = new Characteristic::PositionState( 2 ); obstructionDetected = new Characteristic::ObstructionDetected( possibleJam ); Serial.println( "Persiana HomeKit creada" ); } boolean update() override { if ( targetPosition->updated() ) { int newTarget = constrain( targetPosition->getNewVal(), 0, 100 ); Serial.print( "HomeKit pide posicion: " ); Serial.print(newTarget); Serial.println("%"); // HomeKit ya está modificando TargetPosition, // por lo que no volvemos a escribirla aquí. requestTarget(newTarget); } return true; } void setCurrent(int value) { value = constrain( value, 0, 100 ); currentPercent = value; currentPosition->setVal( value ); } void setTarget(int value) { value = constrain( value, 0, 100 ); targetPercent = value; targetPosition->setVal( value ); } void setState(int value) { positionState->setVal( value ); } void setObstruction(bool state) { obstructionDetected->setVal( state ); } void loop() override { updateMovement(); } }; // ======================================================= // ALERTA DE POSIBLE ATASCO // ======================================================= void setPossibleJam(bool state) { possibleJam = state; // Guardamos también la alerta para que no desaparezca // simplemente al reiniciar el ESP32. prefs.putBool( "jam", possibleJam ); if (blind) { blind->setObstruction( possibleJam ); } } // ======================================================= // CONTROL DEL MOVIMIENTO // ======================================================= void stopAll() { motorStop(); phase = IDLE; if (blind) { blind->setState(2); blind->setCurrent( currentPercent ); } Serial.println( "Motor parado" ); } // ------------------------------------------------------- // BAJADA // ------------------------------------------------------- void startClosingTo(int target) { target = constrain( target, 0, 100 ); if (target >= currentPercent) { stopAll(); return; } finalRequestedTarget = target; targetPercent = target; movementStartPercent = currentPercent; // Calculamos el tiempo proporcional a la distancia // que tiene que recorrer. movementDuration = (unsigned long) (currentPercent - target) * downTravelTime / 100; movementStartTime = millis(); phase = CLOSING_BY_TIME; if (blind) { blind->setState(0); } motorClose(); Serial.print("Bajando desde "); Serial.print(movementStartPercent); Serial.print("% hasta "); Serial.print(target); Serial.print("% durante "); Serial.print(movementDuration); Serial.println(" ms"); } // ------------------------------------------------------- // SUBIDA // ------------------------------------------------------- void startOpeningToLimit(int target) { finalRequestedTarget = constrain( target, 0, 100 ); movementStartTime = millis(); phase = OPENING_TO_LIMIT; if (blind) { blind->setState(1); } motorOpen(); Serial.print( "Subiendo hasta final de carrera. Objetivo final: " ); Serial.print( finalRequestedTarget ); Serial.println("%"); } // ------------------------------------------------------- // NUEVO OBJETIVO // ------------------------------------------------------- void requestTarget(int target) { target = constrain( target, 0, 100 ); targetPercent = target; if (target == currentPercent) { stopAll(); return; } // Para bajar utilizamos tiempo. if (target < currentPercent) { startClosingTo(target); return; } // Para subir buscamos siempre el final superior. // // Si se pide una posición intermedia, primero subimos // hasta el final de carrera para conocer el 100% real // y después bajamos hasta la posición solicitada. if (target > currentPercent) { startOpeningToLimit(target); return; } } // ======================================================= // ACTUALIZACIÓN DEL MOVIMIENTO // ======================================================= void updateMovement() { if (phase == IDLE) { return; } unsigned long elapsed = millis() - movementStartTime; // ===================================================== // SUBIENDO // ===================================================== if ( phase == OPENING_TO_LIMIT ) { // Llegada normal al final superior. if (limitTopActive()) { motorStop(); currentPercent = 100; // Una llegada correcta al final superior elimina // cualquier alerta anterior de atasco. setPossibleJam(false); if (blind) { blind->setCurrent( 100 ); } Serial.println( "Final superior detectado y validado." ); Serial.println( "Posicion = 100%" ); // Si el objetivo era 100%, hemos terminado. if ( finalRequestedTarget == 100 ) { phase = IDLE; if (blind) { blind->setState(2); } Serial.println( "Objetivo 100% alcanzado" ); return; } // Para una posición intermedia: // después de encontrar el 100%, bajamos // hasta el porcentaje solicitado. startClosingTo( finalRequestedTarget ); return; } // --------------------------------------------------- // PROTECCIÓN POR TIEMPO // --------------------------------------------------- // Si la persiana lleva demasiado tiempo subiendo // sin detectar el final, paramos obligatoriamente. if ( elapsed >= maxOpenTime ) { motorStop(); phase = IDLE; setPossibleJam(true); // Como no conocemos la posición física exacta, // HomeKit se deja en 100% y se marca obstrucción. currentPercent = 100; targetPercent = 100; if (blind) { blind->setCurrent(100); blind->setTarget(100); blind->setState(2); blind->setObstruction(true); } Serial.println(); Serial.println( "********************************" ); Serial.println( "ALERTA: POSIBLE ATASCO" ); Serial.println( "Tiempo maximo de subida superado" ); Serial.println( "sin detectar el final de carrera." ); Serial.println( "Motor detenido por seguridad." ); Serial.println( "********************************" ); Serial.println(); return; } return; } // ===================================================== // BAJANDO // ===================================================== if ( phase == CLOSING_BY_TIME ) { if ( elapsed >= movementDuration ) { motorStop(); currentPercent = finalRequestedTarget; phase = IDLE; if (blind) { blind->setCurrent( currentPercent ); blind->setState( 2 ); } Serial.print( "Bajada terminada. Posicion: " ); Serial.print( currentPercent ); Serial.println("%"); return; } // Mientras baja actualizamos una estimación // aproximada de la posición. int movedPercent = elapsed * 100 / downTravelTime; int estimated = movementStartPercent - movedPercent; estimated = constrain( estimated, finalRequestedTarget, movementStartPercent ); currentPercent = estimated; static unsigned long lastUpdate = 0; if ( millis() - lastUpdate > 500 ) { lastUpdate = millis(); if (blind) { blind->setCurrent( currentPercent ); } } } } // ======================================================= // WEB // ======================================================= String motionPhaseText() { if (phase == IDLE) return "PARADO"; if ( phase == OPENING_TO_LIMIT ) return "SUBIENDO HASTA FINAL"; if ( phase == CLOSING_BY_TIME ) return "BAJANDO POR TIEMPO"; return "DESCONOCIDO"; } String htmlPage() { String limitState = limitTopActive() ? "ACTIVO / IMAN DETECTADO" : "NO ACTIVO"; String wifiState = WiFi.status() == WL_CONNECTED ? "CONECTADO" : "DESCONECTADO"; String html = ""; html += ""; html += ""; html += ""; html += ""; html += DEVICE_NAME; html += ""; html += ""; // ----------------------------------------------------- // ALERTA // ----------------------------------------------------- if (possibleJam) { html += "
"; html += "

POSIBLE ATASCO

"; html += "

Se ha superado el tiempo maximo de subida sin detectar el final de carrera.

"; html += "

El motor se ha detenido automaticamente.

"; html += ""; html += "
"; } // ----------------------------------------------------- // ESTADO // ----------------------------------------------------- html += "
"; html += "

"; html += DEVICE_NAME; html += "

"; html += "

Estado: " + motionPhaseText() + "

"; html += "

Posicion actual: " + String(currentPercent) + "%

"; html += "

Objetivo: " + String(targetPercent) + "%

"; html += "

Final carrera superior: " + limitState + "

"; html += "

WiFi: " + wifiState + "

"; html += "

Alerta atasco: "; html += possibleJam ? "ACTIVA" : "NO"; html += "

"; html += "
"; // ----------------------------------------------------- // CONTROL // ----------------------------------------------------- html += "
"; html += "

Control rapido

"; html += "
"; html += "
"; html += "
"; html += "
"; html += "
"; html += ""; html += "
"; // ----------------------------------------------------- // AJUSTES // ----------------------------------------------------- html += "
"; html += "

Ajustes

"; html += "
"; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += "

Ejemplo: 15000 = 15 segundos

"; html += "
"; html += "
"; // ----------------------------------------------------- // CALIBRACIÓN // ----------------------------------------------------- html += "
"; html += "

Calibracion manual

"; html += "
"; html += ""; html += "
"; html += ""; return html; } // ======================================================= // RUTAS WEB // ======================================================= void handleRoot() { server.send( 200, "text/html", htmlPage() ); } void handleTarget() { if ( server.hasArg("p") ) { int p = constrain( server.arg("p").toInt(), 0, 100 ); // Esta vez la orden viene de nuestra web, // así que actualizamos también TargetPosition. if (blind) { blind->setTarget( p ); } requestTarget( p ); } server.sendHeader( "Location", "/" ); server.send(303); } void handleStop() { stopAll(); server.sendHeader( "Location", "/" ); server.send(303); } void handleSet() { if ( server.hasArg("up") ) { speedUpPercent = constrain( server.arg("up").toInt(), MIN_SPEED, MAX_SPEED ); } if ( server.hasArg("down") ) { speedDownPercent = constrain( server.arg("down").toInt(), MIN_SPEED, MAX_SPEED ); } if ( server.hasArg("time") ) { unsigned long t = server.arg("time").toInt(); if ( t >= 1000 && t <= 120000 ) { downTravelTime = t; } } if ( server.hasArg("maxUp") ) { unsigned long t = server.arg("maxUp").toInt(); if ( t >= 1000 && t <= 120000 ) { maxOpenTime = t; } } saveSettings(); Serial.println( "Ajustes guardados" ); Serial.print( "Velocidad subida: " ); Serial.print( speedUpPercent ); Serial.println("%"); Serial.print( "Velocidad bajada: " ); Serial.print( speedDownPercent ); Serial.println("%"); Serial.print( "Tiempo bajada completa: " ); Serial.print( downTravelTime ); Serial.println(" ms"); Serial.print( "Tiempo maximo subida: " ); Serial.print( maxOpenTime ); Serial.println(" ms"); server.sendHeader( "Location", "/" ); server.send(303); } void handleClearJam() { setPossibleJam(false); Serial.println( "Alerta de posible atasco borrada" ); server.sendHeader( "Location", "/" ); server.send(303); } void handleMark0() { stopAll(); currentPercent = 0; targetPercent = 0; if (blind) { blind->setCurrent(0); blind->setTarget(0); blind->setState(2); } Serial.println( "Posicion marcada como 0%" ); server.sendHeader( "Location", "/" ); server.send(303); } void handleMark100() { stopAll(); currentPercent = 100; targetPercent = 100; setPossibleJam(false); if (blind) { blind->setCurrent(100); blind->setTarget(100); blind->setState(2); } Serial.println( "Posicion marcada como 100%" ); server.sendHeader( "Location", "/" ); server.send(303); } void setupWebServer() { server.on( "/", handleRoot ); server.on( "/target", handleTarget ); server.on( "/stop", handleStop ); server.on( "/set", handleSet ); server.on( "/clearJam", handleClearJam ); server.on( "/mark0", handleMark0 ); server.on( "/mark100", handleMark100 ); } // La web utiliza el puerto 8080 porque HomeSpan // utiliza el puerto 80 para HomeKit. void startWebServerIfReady() { if ( !webStarted && WiFi.status() == WL_CONNECTED ) { server.begin(); webStarted = true; Serial.println( "Servidor web iniciado" ); Serial.print( "Ajustes: http://" ); Serial.print( WiFi.localIP() ); Serial.println( ":8080" ); } } // ======================================================= // SETUP // ======================================================= void setup() { Serial.begin(115200); pinMode( R_EN, OUTPUT ); pinMode( L_EN, OUTPUT ); pinMode( RPWM, OUTPUT ); pinMode( LPWM, OUTPUT ); pinMode( LIMIT_TOP, INPUT_PULLUP ); motorStop(); loadSettings(); // Damos tiempo a que alimentación y GPIO // se estabilicen antes de iniciar el sistema. delay(1500); initLimitSwitch(); // ===================================================== // POSICIÓN INICIAL // ===================================================== // // Si detectamos el final superior, sabemos // con certeza que la persiana está al 100%. // // Si no lo detectamos, utilizamos 20% como posición // inicial de seguridad. // // El motor nunca se mueve automáticamente al arrancar. if ( limitTopActive() ) { currentPercent = 100; targetPercent = 100; possibleJam = false; prefs.putBool( "jam", false ); Serial.println( "Arranque: final superior activo -> 100%" ); } else { currentPercent = 20; targetPercent = 20; Serial.println( "Arranque: final superior no activo -> 20%" ); } finalRequestedTarget = targetPercent; movementStartPercent = currentPercent; phase = IDLE; setupWebServer(); // ===================================================== // HOMESPAN / HOMEKIT // ===================================================== homeSpan.setPairingCode( PAIRING_CODE ); // Amplía los tiempos de conexión WiFi. homeSpan.setConnectionTimes( 20, 60, 3 ); // Utilizamos nuestra función de conexión WiFi // para mejorar la recuperación después de un // corte de alimentación. homeSpan.setWifiBegin( customWifiBegin ); homeSpan.begin( Category::WindowCoverings, DEVICE_NAME ); new SpanAccessory(); new Service::AccessoryInformation(); new Characteristic::Identify(); new Characteristic::Name( DEVICE_NAME ); new Characteristic::Manufacturer( DEVICE_MANUFACTURER ); new Characteristic::SerialNumber( DEVICE_SERIAL ); new Characteristic::Model( DEVICE_MODEL ); new Characteristic::FirmwareRevision( DEVICE_FIRMWARE ); blind = new BlindHomeKit(); Serial.println(); Serial.println( "Sistema iniciado" ); Serial.print( "Accesorio: " ); Serial.println( DEVICE_NAME ); Serial.print( "Posicion inicial: " ); Serial.print( currentPercent ); Serial.println("%"); Serial.print( "Tiempo maximo subida: " ); Serial.print( maxOpenTime ); Serial.println(" ms"); } // ======================================================= // LOOP // ======================================================= void loop() { // Filtrado continuo del final de carrera. updateLimitSwitch(); // HomeSpan necesita ejecutarse continuamente // para HomeKit, WiFi y sus comandos internos. homeSpan.poll(); // Arrancamos nuestra web cuando haya WiFi. startWebServerIfReady(); if (webStarted) { server.handleClient(); } // Si el WiFi queda bloqueado durante demasiado // tiempo, reiniciamos automáticamente el ESP32. updateWifiWatchdog(); }