hardware 2024 · varios fines de semana · ~12 €

PakuPersianas: controlador de persiana con ESP32, MQTT y OTA

Una persiana de motor eléctrico que solo podías subir y bajar desde el interruptor de la pared. El resultado: ESP32 con dos relés, control por MQTT, web embebida con WebSocket, OTA para actualizar el firmware desde el sofá, y app Android hecha con MCP-B4A. Todo configurable sin tocar el código.

01 — La persiana sin automatizar

El motor de la persiana ya estaba ahí. Solo necesitaba dos señales: "sube" y "baja". Tres cables del interruptor de pared: fase, subir, bajar. El motor tiene un condensador y sus devanados; cuando le aplicas tensión en uno u otro sentido gira en una dirección u otra. El interruptor de pared simplemente conmuta cuál de los dos terminales del motor recibe la fase.

La idea es poner un par de relés entre el interruptor y el motor, controlados por el ESP32. El interruptor de pared sigue funcionando exactamente igual — no tocas nada del cableado original. El ESP32 simplemente cuelga en paralelo con el mismo cableado.

Requisitos que me fijé: que funcione aunque el WiFi o el broker MQTT no estén disponibles (soft-AP siempre activo), que se pueda configurar sin recompilar (todo en flash con Preferences), y que se pueda actualizar el firmware por OTA sin abrir la caja. La versión 3 del firmware, que es la que está aquí, lo cumple todo.

02 — Hardware: ESP32 y dos relés

El corazón es un ESP32 Dev Module estándar de 38 pines. Para controlar el motor uso un módulo de relé doble 5V de tipo active-LOW: la bobina se activa poniendo el pin a GND (LOW) y se desactiva con HIGH. Esto es importante para el código — los relés están HIGH=apagado por defecto, así que al reiniciar el ESP32 el motor siempre queda parado.

Los dos pines de control son el GPIO25 para subir y el GPIO26 para bajar. Nunca se activan los dos a la vez — el código siempre apaga uno antes de encender el otro, con un delay de 50ms de protección para que la inercia del contactor no cree un cortocircuito momentáneo al cambiar de dirección.

El GPIO2 es el LED integrado del ESP32, que se enciende mientras el motor está en movimiento y se apaga cuando para.

La alimentación del ESP32 va por USB o por el pin VIN a 5V. Los módulos de relé suelen tener su propio optoacoplador y funcionan bien alimentados desde el 5V del ESP32, aunque lo ideal es alimentarlos desde la misma fuente que el ESP32 para no sobrecargar el regulador interno.

03 — El esquema KiCad

Captura del esquema KiCad del controlador de persiana PakuPersianas con ESP32 y módulo de relé doble
Captura del esquema en KiCad. ESP32 a la izquierda con los dos pines de control (GPIO25/GPIO26), el módulo de relé doble en el centro, y los bornes de tornillo para el motor y la alimentación a la derecha. Los snubbers no aparecen en el esquema pero van en los bornes del relé (ver sección siguiente).

El esquema incluye los dos relés con sus diodos de protección y optoacopladores, el ESP32 con sus pines de control etiquetados, los bornes de tornillo para el motor (L_SUBE, L_BAJA, NEUTRO) y la alimentación. La PCB se fresó en una CNC GRBL en una sola capa de cobre.

Una nota sobre el cableado del motor: los motores de persiana monofásicos tienen dos devanados de trabajo y uno de arranque con condensador. El esquema de conexión varía según el fabricante, pero la lógica es siempre la misma: un relé aplica tensión al terminal de subida y el otro al de bajada. Nunca los dos a la vez o funde el motor (y probablemente el relé).

04 — Snubbers: imprescindibles con motores

Este es el detalle que más gente se deja y que provoca que los relés se peguen o se fundan antes de tiempo. Un motor es una carga inductiva. Cuando el relé abre el circuito y corta la corriente, el campo magnético del motor colapsa e induce un pico de tensión inversa que puede ser de cientos de voltios en microsegundos. Ese pico hace arco en los contactos del relé, los erosiona, y con el tiempo o se sueldan (el relé se queda pegado, el motor no para) o los contactos quedan tan picados que dejan de hacer buen contacto.

La solución es un snubber RC: una resistencia y un condensador en serie, colocados en paralelo con los contactos del relé. El condensador absorbe el pico de tensión y la resistencia limita la corriente de descarga del condensador cuando el relé cierra de nuevo. Valores típicos para 230V AC con carga inductiva: 100 Ω / 1 W en serie con 100 nF (0,1 µF) X2 rated para 275V AC como mínimo. Los condensadores X2 son los adecuados para aplicaciones de red — no uses condensadores cerámicos normales aquí.

Yo los tengo puestos directamente en los bornes del relé, no en el esquema KiCad. La conexión es sencilla:

              RELÉ (contactos NO)
               ┌─────────────────┐
Línea (L) ────►│ COM         NO  │────► Motor (terminal subida o bajada)
               └─────────────────┘
                      │       │
                    [100Ω]──[100nF X2]     ← snubber en paralelo con los contactos
                      │       │
                   (mismos puntos: COM y NO del relé)

Uno por cada relé: dos en total para este proyecto. Los pegas directamente encima del módulo de relé con un poco de cinta o silicona, con los cables soldados directamente a los terminales COM y NO del relé. Ocupan poquísimo espacio.

Snubber RC barato para proteger los contactos del relé con carga inductiva
El snubber que uso: un módulo de supresor RC comprado por céntimos. Ya viene con la resistencia y el condensador en serie y preparado para soldar. Si el condensador es X2 o no, la verdad es que no lo sé — pero lleva funcionando sin incidencias desde que lo instalé.

Sin snubbers, con la carga inductiva de un motor de persiana, los relés aguantan unos meses. Con snubbers duran años. Es la diferencia entre un proyecto que funciona y uno que da guerra.

05 — Tres formas de controlar

El firmware tiene tres mecanismos de control independientes que comparten la misma lógica interna:

1. MQTT: El ESP32 se suscribe a un topic configurable (por defecto /casa/habitacion/persiana). Los comandos son tres cadenas: "1" para subir, "-1" para bajar, "0" para parar. Cuando recibe un comando de movimiento, arranca el timer de timeout: si no llega un "0" en el tiempo configurado, el motor para solo. Cada 30 segundos publica un heartbeat en <topic>/debug para saber que el ESP32 sigue vivo.

2. Web local con WebSocket: En el puerto 80 hay un servidor web que sirve una página con tres botones (SUBIR / PARAR / BAJAR). Los botones usan WebSocket para enviar los comandos en tiempo real sin recargar la página. El estado actual se muestra en tiempo real en el mismo panel. No hace falta estar en la misma red que el broker MQTT — con estar en la misma WiFi es suficiente.

3. Soft-AP siempre activo: El ESP32 arranca siempre en modo dual (WIFI_AP_STA): se conecta a tu WiFi de casa como cliente (STA) y monta su propia red WiFi (AP) con el nombre configurado. Si el router cae o cambia la contraseña, el AP sigue ahí y puedes conectarte directamente a él para acceder a la web de configuración. Sin necesidad de recompilar ni de resetear a fábrica.

La configuración completa se hace desde la pestaña "Configuración" de la web embebida: SSID y contraseña WiFi, servidor y credenciales MQTT, topic, nombre del dispositivo (usado también como hostname para OTA y DHCP) y timeout del motor. Al guardar, el ESP32 escribe todo en la memoria flash con la librería Preferences y reinicia.

06 — OTA: actualizar sin cables

La librería ArduinoOTA del ESP32 permite subir el firmware por WiFi desde el Arduino IDE (o PlatformIO) igual que lo harías por cable. El ESP32 aparece como un puerto de red en el IDE cuando está en la misma red. Para actualizarlo: abres el IDE, seleccionas el puerto de red del dispositivo, y le das a upload. El ESP32 recibe el nuevo firmware, lo valida y reinicia.

Antes de empezar la actualización OTA, el callback onStart llama a pararMotor() para asegurar que el motor quede parado durante el proceso. Una medida de seguridad básica pero necesaria.

El nombre del host OTA es el mismo que el device_name configurado en la web, así que si tienes varias persianas cada una tiene su propio nombre y aparecen por separado en el IDE.

// OTA: configuración básica
ArduinoOTA.setHostname(device_name);
ArduinoOTA.onStart([]() { pararMotor(); });
ArduinoOTA.begin();

// En el loop, manejar el proceso de actualización
ArduinoOTA.handle();

07 — La app Android

La app Android para controlar la persiana desde el móvil la hice con Basic4Android (B4A), usando el MCP-B4A para que Claude Code escribiera y compilara el código directamente. Sin abrir el IDE de B4A, sin teclear una línea: describía lo que quería y el MCP generaba el layout, el código y compilaba el APK.

La app se conecta al mismo broker MQTT y publica en el mismo topic que el ESP32. Tres botones (SUBIR, PARAR, BAJAR), un log de actividad en pantalla, y también muestra la temperatura y la humedad del salón que otro sensor publica en los topics correspondientes. La configuración del servidor, puerto, usuario y contraseña MQTT se guarda en el dispositivo con KeyValueStore.

' App B4A — control persiana por MQTT
' (Generada con MCP-B4A)

Sub Process_Globals
    Private mqtt As MqttClient
    Dim kvs As KeyValueStore
    ' Topics — ajustar según tu instalación
    Dim topicPersiana As String = "/casa/habitacion/persiana"
    Dim topicTemp     As String = "/casa/habitacion/temperatura"
    Dim topicHumedad  As String = "/casa/habitacion/humedad"
End Sub

Sub Activity_Create(FirstTime As Boolean)
    Activity.LoadLayout("Layout1")
    If FirstTime Then
        kvs.Initialize(File.DirInternal, "mqtt_settings")
    End If
    ' Cargar config guardada
    etServidor.Text = kvs.GetDefault("servidor", "tu-servidor-mqtt")
    etPuerto.Text   = kvs.GetDefault("puerto",   "1883")
    etUsuario.Text  = kvs.GetDefault("usuario",  "tu-usuario")
    etPassword.Text = kvs.GetDefault("password", "")
    etPassword.PasswordMode = True
End Sub

Sub btConectaMqtt_Click
    kvs.Put("servidor", etServidor.Text)
    kvs.Put("puerto",   etPuerto.Text)
    kvs.Put("usuario",  etUsuario.Text)
    kvs.Put("password", etPassword.Text)
    Dim mo As MqttConnectOptions
    mo.Initialize(etUsuario.Text, etPassword.Text)
    Dim clientId As String = Rnd(0,99999) & DateTime.Now
    mqtt.Initialize("mqtt", "tcp://" & etServidor.Text & ":" & etPuerto.Text, clientId)
    mqtt.Connect2(mo)
End Sub

Sub mqtt_Connected(Success As Boolean)
    If Success Then
        mqtt.Subscribe(topicPersiana, 0)
        mqtt.Subscribe(topicTemp, 0)
        mqtt.Subscribe(topicHumedad, 0)
    End If
End Sub

Sub mqtt_MessageArrived(Topic As String, Payload() As Byte)
    Dim datos As String = BytesToString(Payload, 0, Payload.Length, "utf8")
    If Topic = topicTemp    Then lbTemp.Text = datos & "°C" : Return
    If Topic = topicHumedad Then lbH.Text    = datos & "%" : Return
End Sub

Private Sub sube_Click : mqtt.Publish(topicPersiana, "1".GetBytes("UTF8"))  : End Sub
Private Sub para_Click : mqtt.Publish(topicPersiana, "0".GetBytes("UTF8"))  : End Sub
Private Sub baja_Click : mqtt.Publish(topicPersiana, "-1".GetBytes("UTF8")) : End Sub

08 — El código del ESP32

El firmware completo en dos ficheros: pakupersianas_v3.ino con toda la lógica, y webpage.h con la página HTML/CSS/JS servida desde la memoria flash del ESP32. Las dependencias son todas instalables desde el gestor de librerías de Arduino IDE o PlatformIO: AsyncTCP, ESPAsyncWebServer, AsyncMQTT_ESP32 y ArduinoJson.

Los valores por defecto del código son placeholders: antes de flashear, configura al menos el nombre del dispositivo y los datos MQTT, o déjalo con los defaults y configúralo desde la web una vez que esté encendido y conectado al AP.

// PakuPersianas v3 — ESP32 + MQTT + OTA
// Diciembre 2025

#include <Arduino.h>
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <Preferences.h>
#include <AsyncMQTT_ESP32.h>
#include <ArduinoJson.h>
#include <ArduinoOTA.h>
#include "webpage.h"

// --- PINES ---
const int PIN_SUBE_PERSIANA = 25;
const int PIN_BAJA_PERSIANA = 26;
const int LED_STATUS        = 2;   // LED integrado

// --- DEFAULTS (se sobreescriben desde la web/flash) ---
const char* DEFAULT_AP_SSID     = "persianAP";
const char* DEFAULT_AP_PASS     = "tu-password-ap";
const char* DEFAULT_MQTT_SERVER = "tu-servidor-mqtt";
const char* DEFAULT_MQTT_PORT   = "1883";
const char* DEFAULT_MQTT_USER   = "tu-usuario-mqtt";
const char* DEFAULT_MQTT_PASS   = "tu-password-mqtt";
const char* DEFAULT_TOPIC       = "/casa/habitacion/persiana";
const char* DEFAULT_DEVICE_NAME = "persiana1";
const int   DEFAULT_TIMEOUT     = 9999;   // ms antes de parar el motor

// --- VARIABLES (rellenadas desde Preferences o defaults) ---
char wifi_ssid[40]         = "";
char wifi_pass[40]         = "";
char mqtt_server[40]       = "";
char mqtt_port_str[6]      = "";
char mqtt_user[40]         = "";
char mqtt_pass[40]         = "";
char topicPersianaChar[60] = "";
char device_name[30]       = "";
int  imaxTiempoMotor       = 0;

long millisTiempoMotor     = 0;
unsigned long ultimoHeartbeat = 0;
bool saveConfigNeeded      = false;
bool shouldRestart         = false;

AsyncMqttClient  mqttCliente;
TimerHandle_t    mqttReconnectTimer;
AsyncWebServer   servidorWeb(80);
AsyncWebSocket   ws("/ws");
Preferences      preferences;

void pararMotor() {
    digitalWrite(PIN_SUBE_PERSIANA, HIGH);
    digitalWrite(PIN_BAJA_PERSIANA, HIGH);
    digitalWrite(LED_STATUS, LOW);
}

void notificarWS(String mensaje) {
    ws.textAll("{\"msg\":\"" + mensaje + "\"}");
}

void conectarAMqtt() {
    if (WiFi.status() == WL_CONNECTED) mqttCliente.connect();
}

void WiFiEvent(WiFiEvent_t event) {
    switch(event) {
    case ARDUINO_EVENT_WIFI_STA_GOT_IP:
        conectarAMqtt();
        break;
    case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
        xTimerStop(mqttReconnectTimer, 0);
        break;
    }
}

void onMqttConnect(bool sessionPresent) {
    mqttCliente.subscribe(topicPersianaChar, 1);
    String topicDebug = String(topicPersianaChar) + "/debug";
    mqttCliente.publish(topicDebug.c_str(), 0, false, "ESP32 Online");
    mqttCliente.publish(topicPersianaChar, 0, false, "0");
    notificarWS("MQTT Conectado");
}

// --- LÓGICA DE MOVIMIENTO ---
void onMqttMessage(char* topic, char* payload, AsyncMqttClientMessageProperties p,
                   size_t len, size_t i, size_t t) {
    millisTiempoMotor = millis();
    String msg;
    for (int k = 0; k < len; k++) msg += (char)payload[k];

    if (String(topic) == String(topicPersianaChar)) {
        if (msg == "1") {
            digitalWrite(PIN_BAJA_PERSIANA, HIGH);
            delay(50);
            digitalWrite(PIN_SUBE_PERSIANA, LOW);
            digitalWrite(LED_STATUS, HIGH);
            notificarWS("SUBIENDO...");
        } else if (msg == "-1") {
            digitalWrite(PIN_SUBE_PERSIANA, HIGH);
            delay(50);
            digitalWrite(PIN_BAJA_PERSIANA, LOW);
            digitalWrite(LED_STATUS, HIGH);
            notificarWS("BAJANDO...");
        } else if (msg == "0") {
            millisTiempoMotor = 0;
            pararMotor();
            notificarWS("PARADO");
        }
    }
}

void setup() {
    Serial.begin(115200);
    pinMode(PIN_SUBE_PERSIANA, OUTPUT);
    pinMode(PIN_BAJA_PERSIANA, OUTPUT);
    pinMode(LED_STATUS, OUTPUT);
    digitalWrite(PIN_SUBE_PERSIANA, HIGH);  // relés apagados al arrancar
    digitalWrite(PIN_BAJA_PERSIANA, HIGH);
    digitalWrite(LED_STATUS, LOW);

    WiFi.onEvent(WiFiEvent);

    // Cargar config desde flash
    preferences.begin("pakupersiana", false);
    strncpy(wifi_ssid,          preferences.getString("ssid",     "").c_str(),               40);
    strncpy(wifi_pass,          preferences.getString("pass",     "").c_str(),               40);
    strncpy(mqtt_server,        preferences.getString("m_server", DEFAULT_MQTT_SERVER).c_str(), 40);
    strncpy(mqtt_port_str,      preferences.getString("m_port",   DEFAULT_MQTT_PORT).c_str(),   6);
    strncpy(mqtt_user,          preferences.getString("m_user",   DEFAULT_MQTT_USER).c_str(),  40);
    strncpy(mqtt_pass,          preferences.getString("m_pass",   DEFAULT_MQTT_PASS).c_str(),  40);
    strncpy(topicPersianaChar,  preferences.getString("topic",    DEFAULT_TOPIC).c_str(),      60);
    strncpy(device_name,        preferences.getString("devname",  DEFAULT_DEVICE_NAME).c_str(),30);
    imaxTiempoMotor = preferences.getInt("timeout", DEFAULT_TIMEOUT);
    preferences.end();

    // WiFi dual: AP siempre activo + cliente de tu router
    WiFi.mode(WIFI_AP_STA);
    WiFi.setHostname(device_name);
    WiFi.softAP(DEFAULT_AP_SSID, DEFAULT_AP_PASS);
    if (strlen(wifi_ssid) > 0) WiFi.begin(wifi_ssid, wifi_pass);

    // OTA
    ArduinoOTA.setHostname(device_name);
    ArduinoOTA.onStart([]() { pararMotor(); });
    ArduinoOTA.begin();

    // WebSocket + web server
    ws.onEvent([](AsyncWebSocket*, AsyncWebSocketClient* client,
                  AwsEventType type, void* arg, uint8_t* data, size_t len) {
        if (type == WS_EVT_DATA) {
            data[len] = 0;
            onMqttMessage(topicPersianaChar, (char*)data, {}, len, 0, len);
        } else if (type == WS_EVT_CONNECT) {
            client->text("{\"msg\":\"Conectado\"}");
        }
    });
    servidorWeb.addHandler(&ws);
    servidorWeb.on("/", HTTP_GET, [](AsyncWebServerRequest* req) {
        req->send_P(200, "text/html", index_html,
            [](const String& var) -> String {
                if(var=="SSID")    return wifi_ssid;
                if(var=="PASS")    return wifi_pass;
                if(var=="MSERVER") return mqtt_server;
                if(var=="MPORT")   return mqtt_port_str;
                if(var=="MUSER")   return mqtt_user;
                if(var=="MPASS")   return mqtt_pass;
                if(var=="TOPIC")   return topicPersianaChar;
                if(var=="DEVNAME") return device_name;
                if(var=="TIMEOUT") return String(imaxTiempoMotor);
                return String();
            });
    });
    servidorWeb.on("/saveconfig", HTTP_POST, [](AsyncWebServerRequest* req) {
        if (req->hasParam("ssid",     true)) strncpy(wifi_ssid,         req->getParam("ssid",     true)->value().c_str(), 40);
        if (req->hasParam("pass",     true)) strncpy(wifi_pass,         req->getParam("pass",     true)->value().c_str(), 40);
        if (req->hasParam("m_server", true)) strncpy(mqtt_server,       req->getParam("m_server", true)->value().c_str(), 40);
        if (req->hasParam("m_port",   true)) strncpy(mqtt_port_str,     req->getParam("m_port",   true)->value().c_str(),  6);
        if (req->hasParam("m_user",   true)) strncpy(mqtt_user,         req->getParam("m_user",   true)->value().c_str(), 40);
        if (req->hasParam("m_pass",   true)) strncpy(mqtt_pass,         req->getParam("m_pass",   true)->value().c_str(), 40);
        if (req->hasParam("topic",    true)) strncpy(topicPersianaChar, req->getParam("topic",    true)->value().c_str(), 60);
        if (req->hasParam("devname",  true)) strncpy(device_name,       req->getParam("devname",  true)->value().c_str(), 30);
        if (req->hasParam("timeout",  true)) imaxTiempoMotor = req->getParam("timeout", true)->value().toInt();
        saveConfigNeeded = true;
        req->send(200, "text/html", "<h1>Guardado. Reiniciando...</h1><a href='/'>Volver</a>");
        shouldRestart = true;
    });
    servidorWeb.begin();

    // MQTT
    mqttReconnectTimer = xTimerCreate("mqttTimer", pdMS_TO_TICKS(5000), pdFALSE,
                                       (void*)0, reinterpret_cast<TimerCallbackFunction_t>(conectarAMqtt));
    mqttCliente.onConnect(onMqttConnect);
    mqttCliente.onMessage(onMqttMessage);
    mqttCliente.setServer(mqtt_server, atoi(mqtt_port_str));
    if (strlen(mqtt_user) > 0) mqttCliente.setCredentials(mqtt_user, mqtt_pass);
    mqttCliente.setClientId(device_name);
}

void loop() {
    ArduinoOTA.handle();
    ws.cleanupClients();

    // Heartbeat MQTT cada 30 segundos
    if (millis() - ultimoHeartbeat > 30000) {
        ultimoHeartbeat = millis();
        if (WiFi.status() == WL_CONNECTED && mqttCliente.connected()) {
            String topicDebug = String(topicPersianaChar) + "/debug";
            mqttCliente.publish(topicDebug.c_str(), 0, false,
                                ("Alive: " + String(millis()/1000) + "s").c_str());
        }
    }

    // Guardar config en flash si se solicitó
    if (saveConfigNeeded) {
        saveConfigNeeded = false;
        preferences.begin("pakupersiana", false);
        preferences.putString("ssid",     wifi_ssid);
        preferences.putString("pass",     wifi_pass);
        preferences.putString("m_server", mqtt_server);
        preferences.putString("m_port",   mqtt_port_str);
        preferences.putString("m_user",   mqtt_user);
        preferences.putString("m_pass",   mqtt_pass);
        preferences.putString("topic",    topicPersianaChar);
        preferences.putString("devname",  device_name);
        preferences.putInt("timeout",     imaxTiempoMotor);
        preferences.end();
    }

    if (shouldRestart) { delay(2000); ESP.restart(); }

    // Reconexión MQTT si WiFi está pero MQTT no
    if (WiFi.status() == WL_CONNECTED && !mqttCliente.connected())
        if (xTimerIsTimerActive(mqttReconnectTimer) == pdFALSE)
            xTimerStart(mqttReconnectTimer, 0);

    // Timeout de seguridad del motor
    if (millisTiempoMotor != 0 && (millis() - millisTiempoMotor) > imaxTiempoMotor) {
        millisTiempoMotor = 0;
        pararMotor();
        if (mqttCliente.connected()) mqttCliente.publish(topicPersianaChar, 0, false, "0");
        notificarWS("PARADA (Auto)");
    }
}

El fichero webpage.h contiene la interfaz web completa como una cadena de texto en PROGMEM — se sirve directamente desde la flash del ESP32 sin ocupar RAM. La interfaz tiene dos pestañas: Control (con los tres botones y el estado en tiempo real vía WebSocket) y Configuración (formulario POST con todos los parámetros). Las variables entre % las rellena la función processor del servidor web con los valores actuales.