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
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.
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.