El mando de la persiana del curro se quedaba sin pilas en los peores momentos. En vez de cambiarlas cada dos meses, monté un cacharrito que pulsa el botón del propio mando de forma remota: ESP32 conectado a la WiFi del curro, un MOSFET que hace de dedo electrónico, y el mando pegado encima sin pila. Ahora levanto la persiana desde el móvil. Sin tocar nada de la persiana, sin hackear el protocolo del mando, sin nada. Solo un dedo que aprieta un botón.
01 — La idea: no hackear nada, solo pulsar
La solución obvia cuando quieres controlar algo con mando a distancia es decodificar el protocolo RF, comprar un módulo 433/868 MHz y replicar la señal. Eso está bien si tienes tiempo o quieres aprender. Yo no quería ninguna de las dos cosas: quería levantar la persiana desde el móvil antes del lunes siguiente.
La solución bruta es más simple: coger el mando original, conectar un transistor en paralelo al botón, y activar ese transistor desde el ESP32. El mando sigue siendo el mando, con su frecuencia propietaria, su código rodante, lo que sea. Tú solo le das corriente al botón durante 2 segundos. Ya está.
La única particularidad es que el mando no lleva pila: la alimentación del mando viene directamente de la placa, conectada a los terminales de la pila mediante un par de cables.
02 — El hardware: ESP32 + IRLZ44N + fuente AC
Componentes principales:
- WEMOS D1 Mini ESP32 — el cerebro. WiFi integrado, fácil de programar desde Arduino IDE.
- IRLZ44N — MOSFET N-channel lógico (Vgs(th) ~1-2 V), se activa directamente con los 3,3 V del GPIO del ESP32 sin necesidad de driver.
- Resistencia 10 kΩ — pull-down en el gate, asegura que el MOSFET esté cerrado en el arranque.
- Condensador 100 nF — desacoplo en la alimentación.
- Fuente AC/DC compacta 220V → 12V/5W — módulo de los chinos que cabe en cualquier caja de registro. La salida de 12V pasa por un regulador lineal 7805 para dar los 5V que necesita el ESP32. Aviso: el 7805 es un regulador lineal: disipa como calor la diferencia de tensión multiplicada por la corriente, o sea, con el ESP32 tirando ~200 mA estás regalando ≈1,4 W en calor sin necesidad. Para este uso no es un problema, pero si lo repites lo suyo es usar un módulo de 5V directo (HLK-5M05, HLK-PM01) o un buck converter (LM2596, MP1584). Yo lo usé porque era lo que tenía en el cajón en ese momento.
- Borniers KF2510 — para el botón del mando, la alimentación del mando (terminals de pila), la entrada 220V y la salida 12V.
El GPIO32 del ESP32 se activa durante 2 segundos. Mientras está en HIGH, el IRLZ44N conduce y cortocircuita los terminales del botón del mando — exactamente lo mismo que haría tu dedo. Al bajar el GPIO, el MOSFET corta y el mando deja de estar pulsado.
03 — PCB fresada en la CNC
04 — Conexiones: diagrama
El único punto que merece explicación es la alimentación del mando: los terminales donde iba la pila se conectan directamente a la placa (5V o la tensión de la pila original). El mando funciona igual que siempre, solo que "su pila" ahora viene de la red eléctrica.
05 — Firmware ESP32
Tres formas de activar el pulso de 2 segundos:
- MQTT: publica
"1"en el topiccasa/mando/activar. El ESP32 publica el estado encasa/mando/estado("activado"/"parado"). - Web + WebSocket: abre
http://<ip-del-esp32>/en cualquier navegador. El botón envía un mensaje WebSocket ({"action":"activate"}) y el estado se actualiza en tiempo real sin recargar la página. - Página de configuración:
/configpermite cambiar el SSID, contraseña WiFi, servidor MQTT, puerto, usuario y contraseña sin recompilar. Se guarda en flash conPreferences.
Al arrancar, el ESP32 levanta siempre un AP de configuración (Mando_Config) en paralelo con la conexión a la red WiFi guardada. Si no hay red configurada, el AP sirve para la primera puesta en marcha.
Un detalle práctico: cuando se va la luz y vuelve, el ESP32 arranca en unos 2 segundos y el router tarda 1-2 minutos. El código espera hasta 5 minutos al WiFi y, si no consigue conectar, se resetea solo. Con un router que tarda 90 segundos en levantarse, el segundo intento siempre funciona.
La lógica anti-rebote es simple: si el mando ya está activo, una segunda orden se ignora. El temporizador se gestiona en el loop() con millis(), sin bloquear.
06 — App Android en B4A
Además de la web embebida, hay una app Android mínima hecha en Basic4Android (B4A): un botón para conectar al broker MQTT y otro para enviar el comando de activación. El log de la pantalla muestra las respuestas del ESP32 en tiempo real.
La app usa la librería jmqtt de B4A. Al pulsar "Conectar" se inicializa el cliente con las credenciales, y al pulsar "Activar Mando" publica "1" en el topic correspondiente. Se suscribe también al topic de estado para confirmar que el ESP32 respondió.
07 — Código completo
Datos de red y MQTT cambiados por valores de ejemplo.
pakumandocolortech.ino
// Mando simple para ESP32 D1 Mini
// Activa un pin por 2 segundos y notifica por MQTT.
// Controlable desde página web (WebSocket) y MQTT.
#include <Arduino.h>
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <Preferences.h>
#include <AsyncMQTT_ESP32.h>
#include <ArduinoJson.h>
#include "webpage.h"
// --- CONFIGURACIÓN DE PINES Y RED ---
#define PIN_MANDO 32
const char* AP_SSID = "Mando_Config";
const char* AP_PASS = "ConfigAP123";
// Variables de red (se cargan desde flash)
char wifi_ssid[40] = "";
char wifi_pass[40] = "";
char mqtt_server[40] = "mqtt.tuservidor.com";
char mqtt_port_str[6] = "1883";
char mqtt_user[40] = "usuario_mqtt";
char mqtt_pass[40] = "password_mqtt";
// --- TOPICS MQTT ---
const char* TOPIC_ESTADO = "casa/mando/estado";
const char* TOPIC_ACTIVAR = "casa/mando/activar";
// --- ESTADO ---
bool mandoActivo = false;
unsigned long tiempoInicioActivacion = 0;
const long duracionActivacion = 2000;
// --- OBJETOS ---
AsyncMqttClient mqttCliente;
TimerHandle_t mqttReconnectTimer;
AsyncWebServer servidorWeb(80);
AsyncWebSocket ws("/ws");
Preferences preferences;
// --- PROTOTIPOS ---
void conectarAMqtt();
String processor(const String& var);
void onWsEvent(AsyncWebSocket*, AsyncWebSocketClient*, AwsEventType, void*, uint8_t*, size_t);
void onMqttConnect(bool sessionPresent);
void onMqttDisconnect(AsyncMqttClientDisconnectReason reason);
void onMqttMessage(char*, char*, AsyncMqttClientMessageProperties, size_t, size_t, size_t);
void activarMando();
// --- PÁGINA DE CONFIGURACIÓN ---
const char config_html[] PROGMEM = R"rawliteral(
<!DOCTYPE HTML><html><head>
<title>Mando - Config Red</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body{font-family:Arial,sans-serif;background-color:#f0f2f5;padding:1em;}
.container{max-width:500px;margin:0 auto;background:#fff;padding:20px;border-radius:8px;box-shadow:0 2px 10px rgba(0,0,0,.1);}
h1{color:#0056b3;text-align:center;}
label{display:block;margin-top:10px;font-weight:bold;}
input{width:100%;padding:8px;border-radius:4px;border:1px solid #ccc;box-sizing:border-box;}
button{width:100%;background:#007bff;color:#fff;padding:12px;border:none;border-radius:4px;cursor:pointer;font-size:16px;margin-top:20px;}
button:hover{background:#0056b3;}
.msg{background:#e9f7ef;color:#1d643b;padding:10px;border-radius:4px;margin-bottom:15px;text-align:center;}
</style>
</head><body><div class="container">
<h1>Configuración de Red</h1>
<div class="msg">Red actual: <strong>%WIFI_SSID%</strong></div>
<form action="/saveconfig" method="POST">
<label>SSID WiFi:</label><input type="text" name="ssid" value="%WIFI_SSID%">
<label>Contraseña WiFi:</label><input type="password" name="pass">
<hr style="margin-top:20px;border:1px solid #ddd;">
<label>Servidor MQTT:</label><input type="text" name="server" value="%MQTT_SERVER%">
<label>Puerto MQTT:</label><input type="text" name="port" value="%MQTT_PORT%">
<label>Usuario MQTT:</label><input type="text" name="user" value="%MQTT_USER%">
<label>Contraseña MQTT:</label><input type="password" name="mqtt_pass">
<button type="submit">Guardar y Reiniciar</button>
</form></div></body></html>
)rawliteral";
void setup() {
Serial.begin(115200);
delay(500);
pinMode(PIN_MANDO, OUTPUT);
digitalWrite(PIN_MANDO, LOW);
// Cargar configuración de red desde flash
preferences.begin("mando-d1-cfg", true);
strncpy(wifi_ssid, preferences.getString("wifi_ssid", "").c_str(), sizeof(wifi_ssid) - 1);
strncpy(wifi_pass, preferences.getString("wifi_pass", "").c_str(), sizeof(wifi_pass) - 1);
strncpy(mqtt_server, preferences.getString("mqtt_server", "mqtt.tuservidor.com").c_str(), sizeof(mqtt_server) - 1);
strncpy(mqtt_port_str,preferences.getString("mqtt_port", "1883").c_str(), sizeof(mqtt_port_str)- 1);
strncpy(mqtt_user, preferences.getString("mqtt_user", "usuario_mqtt").c_str(), sizeof(mqtt_user) - 1);
strncpy(mqtt_pass, preferences.getString("mqtt_pass", "").c_str(), sizeof(mqtt_pass) - 1);
preferences.end();
// AP+STA: siempre hay AP para configuración
WiFi.mode(WIFI_AP_STA);
WiFi.softAP(AP_SSID, AP_PASS);
if (strlen(wifi_ssid) > 0) {
WiFi.begin(wifi_ssid, wifi_pass);
// Espera hasta 5 minutos (600 × 500 ms). Si no conecta, reinicia.
// Necesario cuando el ESP32 arranca antes que el router tras un corte de luz.
int retries = 0;
while (WiFi.status() != WL_CONNECTED && retries < 600) {
delay(500);
retries++;
}
if (WiFi.status() != WL_CONNECTED) {
delay(1000);
ESP.restart();
}
}
// Rutas web
ws.onEvent(onWsEvent);
servidorWeb.addHandler(&ws);
servidorWeb.on("/", HTTP_GET, [](AsyncWebServerRequest *r){ r->send(200, "text/html", index_html); });
servidorWeb.on("/config", HTTP_GET, [](AsyncWebServerRequest *r){ r->send(200, "text/html", config_html, processor); });
servidorWeb.on("/saveconfig",HTTP_POST, [](AsyncWebServerRequest *r){
preferences.begin("mando-d1-cfg", false);
if (r->hasParam("ssid", true)) preferences.putString("wifi_ssid", r->getParam("ssid", true)->value());
if (r->hasParam("pass", true)) preferences.putString("wifi_pass", r->getParam("pass", true)->value());
if (r->hasParam("server", true)) preferences.putString("mqtt_server", r->getParam("server", true)->value());
if (r->hasParam("port", true)) preferences.putString("mqtt_port", r->getParam("port", true)->value());
if (r->hasParam("user", true)) preferences.putString("mqtt_user", r->getParam("user", true)->value());
if (r->hasParam("mqtt_pass", true)) preferences.putString("mqtt_pass", r->getParam("mqtt_pass", true)->value());
preferences.end();
r->send(200, "text/html", "<h1>Guardado. Reiniciando en 5 s...</h1>");
delay(5000);
ESP.restart();
});
servidorWeb.begin();
// MQTT (solo si hay WiFi)
if (WiFi.status() == WL_CONNECTED) {
mqttReconnectTimer = xTimerCreate("mqttTimer", pdMS_TO_TICKS(5000), pdFALSE, (void*)0,
reinterpret_cast<TimerCallbackFunction_t>(conectarAMqtt));
mqttCliente.onConnect(onMqttConnect);
mqttCliente.onDisconnect(onMqttDisconnect);
mqttCliente.onMessage(onMqttMessage);
mqttCliente.setServer(mqtt_server, atoi(mqtt_port_str));
if (strlen(mqtt_user) > 0) mqttCliente.setCredentials(mqtt_user, mqtt_pass);
mqttCliente.setClientId("ESP32_D1_Mini_Mando");
conectarAMqtt();
}
}
void loop() {
ws.cleanupClients();
// Desactivar el pin pasados los 2 segundos
if (mandoActivo && (millis() - tiempoInicioActivacion >= duracionActivacion)) {
digitalWrite(PIN_MANDO, LOW);
mandoActivo = false;
if (mqttCliente.connected()) mqttCliente.publish(TOPIC_ESTADO, 0, true, "parado");
ws.textAll("{\"status\":\"parado\"}");
}
}
void activarMando() {
if (mandoActivo) return; // Ignora si ya está en curso
digitalWrite(PIN_MANDO, HIGH);
mandoActivo = true;
tiempoInicioActivacion = millis();
if (mqttCliente.connected()) mqttCliente.publish(TOPIC_ESTADO, 0, true, "activado");
ws.textAll("{\"status\":\"activado\"}");
}
String processor(const String& var) {
if(var == "WIFI_SSID") return String(wifi_ssid);
if(var == "MQTT_SERVER") return String(mqtt_server);
if(var == "MQTT_PORT") return String(mqtt_port_str);
if(var == "MQTT_USER") return String(mqtt_user);
return String();
}
void onWsEvent(AsyncWebSocket *s, AsyncWebSocketClient *c, AwsEventType type, void *arg, uint8_t *data, size_t len) {
if (type == WS_EVT_CONNECT) {
c->text(mandoActivo ? "{\"status\":\"activado\"}" : "{\"status\":\"parado\"}");
} else if (type == WS_EVT_DATA) {
JsonDocument doc;
if (deserializeJson(doc, (char*)data, len) == DeserializationError::Ok) {
if (strcmp(doc["action"], "activate") == 0) activarMando();
}
}
}
void conectarAMqtt() {
if (WiFi.status() == WL_CONNECTED) mqttCliente.connect();
}
void onMqttConnect(bool sessionPresent) {
mqttCliente.subscribe(TOPIC_ACTIVAR, 1);
}
void onMqttDisconnect(AsyncMqttClientDisconnectReason reason) {
if (WiFi.isConnected()) xTimerStart(mqttReconnectTimer, 0);
}
void onMqttMessage(char* topic, char* payload, AsyncMqttClientMessageProperties p, size_t len, size_t idx, size_t total) {
String msg;
msg.reserve(len);
for (size_t i = 0; i < len; i++) msg += (char)payload[i];
if (strcmp(topic, TOPIC_ACTIVAR) == 0 && msg == "1") activarMando();
}
webpage.h
#ifndef WEBPAGE_H
#define WEBPAGE_H
const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE HTML><html><head>
<title>Control Mando</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: Arial, sans-serif; background: #f0f2f5; margin: 0; padding: 2em;
display: flex; justify-content: center; align-items: center; height: 100vh; }
.container { max-width: 400px; width: 100%; background: #fff; padding: 30px;
border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,.15); text-align: center; }
h1 { color: #0056b3; margin-bottom: 20px; }
#activateButton {
background: #28a745; color: white; font-size: 22px; font-weight: bold;
padding: 20px 40px; border: none; border-radius: 8px; cursor: pointer;
transition: background .3s, transform .1s; width: 100%;
}
#activateButton:hover { background: #218838; }
#activateButton:active { transform: scale(.98); }
#activateButton:disabled { background: #ccc; cursor: not-allowed; }
#status { margin-top: 25px; font-size: 1.2em; color: #555; font-weight: bold; }
.status-on { color: #dc3545; }
.status-off { color: #28a745; }
footer { text-align: center; margin-top: 30px; font-size: .9em; color: #666; }
</style>
</head><body>
<div class="container">
<h1>Control Remoto</h1>
<button id="activateButton" onclick="activar()">Activar Mando</button>
<p id="status">Estado: <span id="statusText" class="status-off">Parado</span></p>
<footer><p><a href="/config">Configuración de red</a></p></footer>
</div>
<script>
const gateway = `ws://${window.location.hostname}/ws`;
let websocket;
const btn = document.getElementById('activateButton');
const txt = document.getElementById('statusText');
window.addEventListener('load', () => initWebSocket());
function initWebSocket() {
websocket = new WebSocket(gateway);
websocket.onclose = () => setTimeout(initWebSocket, 2000);
websocket.onmessage = (e) => {
const d = JSON.parse(e.data);
if (d.status === 'activado') {
txt.textContent = 'Activado'; txt.className = 'status-on';
btn.disabled = true; btn.textContent = 'Activado...';
} else {
txt.textContent = 'Parado'; txt.className = 'status-off';
btn.disabled = false; btn.textContent = 'Activar Mando';
}
};
}
function activar() {
if (websocket && websocket.readyState === WebSocket.OPEN)
websocket.send(JSON.stringify({ action: "activate" }));
else
alert('WebSocket no conectado.');
}
</script>
</body></html>
)rawliteral";
#endif
MQTT.b4a — app Android (B4A)
' --- pakuMando para B4A ---
' Librería necesaria: jmqtt
#Region Project Attributes
#ApplicationLabel: Mando MQTT
#VersionCode: 1
#VersionName: 1.0.0
#SupportedOrientations: portrait
#CanInstallToExternalStorage: False
#End Region
Sub Process_Globals
Private mqtt As MqttClient
Dim vbcrlf As String
vbcrlf = Chr(10) & Chr(13)
End Sub
Sub Globals
Private tbLog As Label
Private btnConectar As Button
Private btnActivarMando As Button
Private lblEstado As Label
' --- CONFIGURACIÓN MQTT ---
Dim mqttServer As String = "mqtt.tuservidor.com"
Dim mqttPort As Int = 1883
Dim mqttUser As String = "usuario_mqtt"
Dim mqttPass As String = "password_mqtt"
Dim topicActivar As String = "casa/mando/activar"
Dim topicEstado As String = "casa/mando/estado"
End Sub
Sub Activity_Create(FirstTime As Boolean)
Activity.LoadLayout("LayoutMando")
btnActivarMando.Enabled = False
End Sub
Sub Activity_Pause (UserClosed As Boolean)
If UserClosed Then
If mqtt.IsInitialized And mqtt.Connected Then mqtt.Close
End If
End Sub
' --- EVENTOS MQTT ---
Sub mqtt_Connected (Success As Boolean)
If Success Then
tbLog.Text = tbLog.Text & vbcrlf & "Conectado al broker MQTT."
btnConectar.Enabled = False
btnActivarMando.Enabled = True
mqtt.Subscribe(topicEstado, 0)
Else
tbLog.Text = tbLog.Text & vbcrlf & "Error: " & LastException
End If
End Sub
Private Sub mqtt_Disconnected
tbLog.Text = tbLog.Text & vbcrlf & "Desconectado."
btnConectar.Enabled = True
btnActivarMando.Enabled = False
End Sub
Private Sub mqtt_MessageArrived (Topic As String, Payload() As Byte)
Dim msg As String = BytesToString(Payload, 0, Payload.Length, "UTF8")
If Topic = topicEstado Then
lblEstado.Text = "Estado: " & msg.ToUpperCase
tbLog.Text = tbLog.Text & vbcrlf & "Respuesta: " & msg
End If
End Sub
' --- BOTONES ---
Private Sub btnConectar_Click
If mqtt.IsInitialized And mqtt.Connected Then Return
Dim clientId As String = "B4A_Mando_" & Rnd(0, 99999)
Dim mo As MqttConnectOptions
mo.Initialize(mqttUser, mqttPass)
mqtt.Initialize("mqtt", "tcp://" & mqttServer & ":" & mqttPort, clientId)
tbLog.Text = "Conectando a " & mqttServer & "..."
mqtt.Connect2(mo)
End Sub
Private Sub btnActivarMando_Click
If mqtt.Connected = False Then
tbLog.Text = tbLog.Text & vbcrlf & "No conectado."
Return
End If
mqtt.Publish(topicActivar, "1".GetBytes("UTF8"))
tbLog.Text = tbLog.Text & vbcrlf & "Comando enviado."
End Sub