hardware 2025 hardware · ¿Harto de quedarte sin pilas en el mando cuando vas a levantar la persiana?

Pulsador remoto WiFi/MQTT para cualquier mando: persiana, portón, lo que sea

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:

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

PCB recién fresada en la CNC: se ven las pistas de cobre aisladas y el texto grabado con etiquetas de cada punto de conexión
La PCB recién salida de la CNC. Las etiquetas están grabadas en el propio cobre: 10k, boton, pila, irlz44n, 5v, 12v, GND, SHB-5W1, 220V. Así no hay que acordarse de qué va en cada pad.
PCB soldada con todos los componentes: IRLZ44N, borniers verdes en los bordes, puntos de soldadura limpios
La placa soldada. El IRLZ44N en el centro, borniers en los cuatro puntos de conexión externos.
Vista de conjunto antes del montaje final: ESP32 D1 Mini, IRLZ44N, fuente AC/DC compacta y borniers sobre la PCB
Antes de cerrarlo en la caja. El módulo negro grande es la fuente AC/DC compacta de 220V. El D1 Mini azul a la izquierda. El IRLZ44N TO-220 justo delante de la fuente.
Sistema completo montado y cableado: ESP32, fuente verde AC/DC y módulo adicional con cables
El sistema completo con cables, listo para meter en la caja y enchufar.

04 — Conexiones: diagrama

GND L N Fuente AC/DC 220 V → 12 V 12V 7805 12V→5V 5V ESP32 D1 Mini WiFi / MQTT 5V GPIO32 10kΩ pull-down IRLZ44N N-MOSFET G D S MANDO botón alimentación del mando: ⊕ pila+ → raíl 5V ⊖ pila− → GND Drain/botón Señal gate 5V GND dashed=pila/pull-down

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:

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