Community project
Universal Remote Control Hub
This universal remote control hub brings together infrared, 433 MHz radio frequency, and motion detection into a single ESP32-based device with an OLED display interface. The project captures and replays IR signals from existing remotes, transmits and receives 433 MHz RF commands, and includes a motion radar sensor for automation scenarios. Builders will receive a complete wiring diagram, detailed parts list, pre-configured firmware with categorized menu system, and step-by-step assembly instructions.
The guide covers all hardware connections from the SSD1306 display and navigation buttons to the IR receiver and transmitter circuit, 433 MHz transceiver modules, and motion sensor. Firmware setup includes library installation, pin configuration, and LittleFS storage for recording remote signals. Battery power integration and pre-power verification steps ensure safe operation before first startup.
Wiring diagram

Gather all the parts
Assemble it in 8 steps
1. Preparar la placa
Desconecta el USB. Usa una placa ESP32-WROOM-32 de 38 pines y conecta todas las tierras GND entre sí. Consulta los nombres impresos en cada módulo, no solo el orden físico de las patas.
2. Conectar la pantalla
OLED VCC a 3V3, GND a GND, SDA a GPIO21 para datos y SCL a GPIO22 para reloj. La dirección es 0x3C.
3. Conectar los botones
Conecta un terminal de cada botón a GND. El otro va a GPIO32 para arriba, GPIO33 para abajo, GPIO25 para OK y GPIO35 para atrás. Conecta la resistencia de 10 kΩ entre GPIO35 y 3V3 para mantener estable ese botón.
4. Conectar infrarrojos
Receptor VS1838B VCC a 3V3, GND a GND y OUT a GPIO27. Conecta GPIO4 a la base del 2N2222A mediante 1 kΩ; emisor a GND y colector al cátodo del LED IR. Conecta el ánodo del LED a 3V3 mediante 100 Ω.
- Comprueba la disposición de las patas del transistor y receptor según sus etiquetas o fichas; puede variar entre fabricantes.
5. Conectar la radio de 433 MHz y el radar
RX470C VCC a 3V3, GND a GND y DATA a GPIO34. WL102 + a 3V3, - a GND y DAT a GPIO16. Radar RCWL VIN a 5V regulados, GND a GND y OUT a GPIO36. Conecta las antenas apropiadas a los contactos de antena de radio.
- El WL102-341 admite máximo 3.6 V; no lo conectes a 5 V. El contacto 3V3 del radar es una salida
- no su entrada de alimentación.
6. Conectar las radios con sus adaptadores de 5 V
Inserta cada nRF24 en el zócalo de su adaptador, orientado según las etiquetas. Con el USB desconectado, conecta VCC de cada adaptador al contacto 5V del ESP32 y GND a GND. En ambos adaptadores conecta SCK a GPIO18 (reloj), MOSI a GPIO23 (datos hacia la radio) y MISO a GPIO19 (datos hacia el ESP32). En el primero conecta CE a GPIO14 y CSN a GPIO17; en el segundo, CE a GPIO13 y CSN a GPIO26. Conecta una resistencia de 10 kΩ entre cada CSN y 3V3. Conserva cada condensador de 10 µF entre VCC y GND del nRF, positivo a la salida regulada de 3.3 V del zócalo y negativo a GND; no al VCC de 5 V del adaptador.
- El adaptador elegido debe tener un regulador de 3.3 V y señales SPI directas. Comprueba con un multímetro antes de insertar la radio que el zócalo entrega aproximadamente 3.3 V, no 5 V. No conectes 5 V directamente al nRF ni a los GPIO; respeta la polaridad de los condensadores.
7. Revisar antes de encender
Comprueba polaridades, resistencias, tierras y ausencia de cortocircuitos. Las conexiones GPIO34, GPIO35, GPIO13 y GPIO26 reemplazan algunos pines del programa original para evitar pines de arranque.
8. Preparar carga y alimentación de batería
Usa un TP4056 de seis contactos con protección B+, B-, OUT+ y OUT-. Conecta la batería POS a B+ y NEG a B-, y OUT+ al contacto central del interruptor. Conecta un lateral del interruptor a VIN+ del elevador MT3608 y OUT- a VIN-. Deja el otro lateral del interruptor libre. Con el elevador desconectado del ESP32, ajusta su salida a 5,0 V con un multímetro. Luego conecta VOUT+ a 5V del ESP32 y VOUT- a GND. Carga por el USB del TP4056 con el interruptor apagado.
- Antes de conectar la batería verifica sus datos: debe ser una celda Li-ion/LiPo de 4,2 V de carga final y admitir al menos 1 A de descarga continua y los picos de arranque. Si no lo indica, usa una batería protegida de mayor capacidad con límite confirmado. 702025 no garantiza capacidad ni corriente.
- El TP4056 suele venir ajustado a 1 A, peligroso para una celda pequeña. No cargues hasta sustituir su resistencia RPROG por una calculada según el límite de carga confirmado para la batería; requiere soldadura fina. Verifica la corriente con un multímetro. La capacidad 300 mAh y autonomía son solo supuestos, no especificaciones de tu celda.
- El TP4056 no gestiona correctamente cargar y alimentar a la vez: apaga durante la carga. No conectes simultáneamente USB del ESP32 y del cargador; para Deploy desconecta físicamente VOUT+ del elevador del pin 5V antes de conectar el USB del ESP32. No unas B- con GND: OUT- pasa por la protección.
- Desconecta todas las fuentes al cablear; comprueba polaridad, ausencia de cortos y 5,0 V antes de conectar la placa. No cargues celdas hinchadas o dañadas, ni fuera de la temperatura permitida por su fabricante; nunca las dejes cargando sin supervisión.
Review all connections
1. Connections between "oled" and "ESP32"
2. Connections between "ir_rx" and "ESP32"
3. Connections between "q1" and "ESP32"
4. Connections between "rbase" and "ESP32"
5. Connections between "ir_led" and "ESP32"
6. Connections between "rled" and "ESP32"
7. Connections between "rf_rx" and "ESP32"
8. Connections between "rf_tx" and "ESP32"
9. Connections between "radar" and "ESP32"
10. Connections between "rpull" and "ESP32"
11. Connections between "arriba" and "ESP32"
12. Connections between "abajo" and "ESP32"
13. Connections between "ok" and "ESP32"
14. Connections between "atras" and "ESP32"
15. Connections between "adaptador1" and "ESP32"
16. Connections between "nrf1" and "ESP32"
17. Connections between "c1" and "ESP32"
18. Connections between "adaptador2" and "ESP32"
19. Connections between "nrf2" and "ESP32"
20. Connections between "c2" and "ESP32"
21. Connections between "nrf1pull" and "ESP32"
22. Connections between "nrf2pull" and "ESP32"
23. Connections between "bateria" and "ESP32"
24. Connections between "cargador" and "ESP32"
25. Connections between "interruptor" and "ESP32"
26. Connections between "elevador" and "ESP32"
Deploy the firmware
/*
Control remoto universal - ESP32-WROOM-32 (38 pines)
Fusion: Fase 1 (IR, menu, LittleFS, log) + RF 315/433 MHz + prueba NRF24L01 x2
Interfaz v2: menu por categorias (IR / RF / Sistema), iconos, indicador de radar.
Librerias (Gestor de librerias):
- IRremoteESP8266 (crankyoldgit)
- Adafruit SSD1306
- Adafruit GFX Library
- RF24 (TMRh20)
- rc-switch (sui77)
LittleFS y SPI ya vienen con el core de ESP32.
OLED confirmada: SSD1306 en 0x3C, SDA=GPIO21, SCL=GPIO22.
Pines:
Botones: Arriba 32, Abajo 33, OK 25, Atras 35 (a GND, INPUT_PULLUP)
IR: RX (VS1838B) 27, TX (LED+2N2222A) 4
RF 315/433: RX (RX470C) 34, TX (WL102) 16
Radar RCWL-0516: OUT 36
NRF24L01 #1: CE 14, CSN 17
NRF24L01 #2: CE 13, CSN 26
NRF24L01 #1 y #2 comparten SCK 18, MOSI 23, MISO 19 (SPI por defecto)
NOTA: las senales IR se guardan en /iN.bin y las RF en /rN.bin.
LOG: Monitor Serie a 115200 baudios.
*/
#include <Arduino.h>
#include <stdarg.h>
#include <Wire.h>
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <IRremoteESP8266.h>
#include <IRrecv.h>
#include <IRsend.h>
#include <IRutils.h>
#include <LittleFS.h>
#include <RCSwitch.h>
#include <RF24.h>
// ---------------------------------------------------------------- Pines
const uint8_t PIN_SDA = 21;
const uint8_t PIN_SCL = 22;
const uint8_t PIN_IR_RX = 27;
const uint8_t PIN_IR_TX = 4;
const uint8_t PIN_UP = 32;
const uint8_t PIN_DOWN = 33;
const uint8_t PIN_OK = 25;
const uint8_t PIN_BACK = 35;
const uint8_t PIN_RF_RX = 34;
const uint8_t PIN_RF_TX = 16;
const uint8_t PIN_RADAR = 36;
const uint8_t PIN_NRF1_CE = 14;
const uint8_t PIN_NRF1_CSN = 17;
const uint8_t PIN_NRF2_CE = 13;
const uint8_t PIN_NRF2_CSN = 26;
// ---------------------------------------------------------------- Pantalla
const uint8_t OLED_ADDR = 0x3C;
const uint8_t OLED_ROTATION = 2;
Adafruit_SSD1306 display(128, 64, &Wire, -1);
// ---------------------------------------------------------------- IR
const uint16_t RX_BUFFER = 1024;
const uint8_t RX_TIMEOUT = 50;
const uint16_t MAX_RAW = 400;
const uint16_t IR_FREQ_KHZ = 38;
IRrecv irrecv(PIN_IR_RX, RX_BUFFER, RX_TIMEOUT, true);
IRsend irsend(PIN_IR_TX);
decode_results irResults;
// ---------------------------------------------------------------- RF 315/433
RCSwitch rcSwitch = RCSwitch();
// ---------------------------------------------------------------- NRF24
RF24 radio1(PIN_NRF1_CE, PIN_NRF1_CSN);
RF24 radio2(PIN_NRF2_CE, PIN_NRF2_CSN);
const byte nrfAddr[6] = "CTRL1";
uint32_t nrfCounter = 0;
// ---------------------------------------------------------------- Almacenamiento IR
const uint8_t MAX_IR_SLOTS = 10;
const uint8_t NAME_LEN = 16;
struct IRHeader { uint16_t len; char name[NAME_LEN]; };
bool irUsed[MAX_IR_SLOTS];
char irName[MAX_IR_SLOTS][NAME_LEN];
uint16_t irLen[MAX_IR_SLOTS];
uint16_t pendingRaw[MAX_RAW];
uint16_t pendingLen = 0;
String pendingProto = "";
uint16_t sendBuf[MAX_RAW];
// ---------------------------------------------------------------- Almacenamiento RF
const uint8_t MAX_RF_SLOTS = 10;
struct RFSlotData { unsigned long value; unsigned int bitlength; unsigned int protocol; char name[NAME_LEN]; };
bool rfUsed[MAX_RF_SLOTS];
char rfName[MAX_RF_SLOTS][NAME_LEN];
unsigned long rfValue[MAX_RF_SLOTS];
unsigned int rfBits[MAX_RF_SLOTS];
unsigned int rfProto[MAX_RF_SLOTS];
unsigned long pendingRFValue = 0;
unsigned int pendingRFBits = 0;
unsigned int pendingRFProto = 0;
bool pendingRFHas = false;
// ---------------------------------------------------------------- Menus por categoria
const char* TOP_ITEMS[] = {"IR", "RF 315/433", "NRF 2.4GHz", "Sistema"};
const uint8_t TOP_COUNT = 4;
const char* SUBIR_ITEMS[] = {"Enviar", "Aprender", "Borrar"};
const uint8_t SUBIR_COUNT = 3;
const char* SUBRF_ITEMS[] = {"Enviar", "Aprender", "Borrar"};
const uint8_t SUBRF_COUNT = 3;
const char* SUBNRF_ITEMS[] = {"Probar enlace"};
const uint8_t SUBNRF_COUNT = 1;
uint8_t topIndex = 0;
uint8_t subIndex = 0;
// ---------------------------------------------------------------- Estados
enum State {
ST_TOP, ST_SUB_IR, ST_SUB_RF, ST_SUB_NRF,
ST_IR_SEND_LIST, ST_IR_LEARN, ST_IR_DELETE_LIST, ST_IR_CONFIRM_DELETE,
ST_RF_SEND_LIST, ST_RF_LEARN, ST_RF_DELETE_LIST, ST_RF_CONFIRM_DELETE,
ST_NRF_TEST,
ST_INFO,
ST_MESSAGE
};
const char* STATE_NAMES[] = {
"TOP", "SUB_IR", "SUB_RF", "SUB_NRF",
"ENVIAR_IR", "APRENDER_IR", "BORRAR_IR", "CONFIRMAR_IR",
"ENVIAR_RF", "APRENDER_RF", "BORRAR_RF", "CONFIRMAR_RF",
"PROBAR_NRF",
"INFO",
"MENSAJE"
};
State state = ST_TOP;
State afterMessage = ST_TOP;
State menuReturn = ST_TOP; // a donde vuelve VOLVER / tras terminar una accion
unsigned long messageUntil = 0;
uint8_t listIndex = 0;
uint8_t listIds[MAX_IR_SLOTS > MAX_RF_SLOTS ? MAX_IR_SLOTS : MAX_RF_SLOTS];
uint8_t listCount = 0;
uint8_t pendingDeleteId = 0;
bool learnHasSignal = false;
unsigned long lastAnim = 0;
// =====================================================================
// LOG
// =====================================================================
void logMsg(const char* fmt, ...) {
char buf[200];
va_list ap;
va_start(ap, fmt);
vsnprintf(buf, sizeof(buf), fmt, ap);
va_end(ap);
unsigned long t = millis();
Serial.printf("[%02lu:%02lu.%03lu] %s\n", t / 60000UL, (t / 1000UL) % 60UL, t % 1000UL, buf);
}
void setState(uint8_t s) {
if ((State)s != state) logMsg("PANTALLA %s -> %s", STATE_NAMES[state], STATE_NAMES[s]);
state = (State)s;
}
// =====================================================================
// ALMACENAMIENTO - IR
// =====================================================================
String irPath(uint8_t i) { return "/i" + String(i) + ".bin"; }
void refreshIRSlots() {
for (uint8_t i = 0; i < MAX_IR_SLOTS; i++) {
irUsed[i] = false; irName[i][0] = '\0'; irLen[i] = 0;
File f = LittleFS.open(irPath(i), "r");
if (f) {
IRHeader h;
if (f.read((uint8_t*)&h, sizeof(h)) == sizeof(h)) {
irUsed[i] = true;
h.name[NAME_LEN - 1] = '\0';
strncpy(irName[i], h.name, NAME_LEN);
irLen[i] = h.len;
}
f.close();
}
}
}
uint8_t irUsedCount() { uint8_t c = 0; for (uint8_t i = 0; i < MAX_IR_SLOTS; i++) if (irUsed[i]) c++; return c; }
int irFirstFree() { for (uint8_t i = 0; i < MAX_IR_SLOTS; i++) if (!irUsed[i]) return i; return -1; }
bool saveIRSlot(uint8_t i, const char* name, const uint16_t* raw, uint16_t len) {
File f = LittleFS.open(irPath(i), "w");
if (!f) return false;
IRHeader h; h.len = len; memset(h.name, 0, NAME_LEN); strncpy(h.name, name, NAME_LEN - 1);
f.write((uint8_t*)&h, sizeof(h));
f.write((const uint8_t*)raw, len * sizeof(uint16_t));
f.close();
return true;
}
uint16_t loadIRSlot(uint8_t i, uint16_t* raw) {
File f = LittleFS.open(irPath(i), "r");
if (!f) return 0;
IRHeader h;
if (f.read((uint8_t*)&h, sizeof(h)) != sizeof(h) || h.len == 0 || h.len > MAX_RAW) { f.close(); return 0; }
f.read((uint8_t*)raw, h.len * sizeof(uint16_t));
f.close();
return h.len;
}
// =====================================================================
// ALMACENAMIENTO - RF
// =====================================================================
String rfPath(uint8_t i) { return "/r" + String(i) + ".bin"; }
void refreshRFSlots() {
for (uint8_t i = 0; i < MAX_RF_SLOTS; i++) {
rfUsed[i] = false; rfName[i][0] = '\0'; rfValue[i] = 0; rfBits[i] = 0; rfProto[i] = 0;
File f = LittleFS.open(rfPath(i), "r");
if (f) {
RFSlotData d;
if (f.read((uint8_t*)&d, sizeof(d)) == sizeof(d)) {
rfUsed[i] = true;
d.name[NAME_LEN - 1] = '\0';
strncpy(rfName[i], d.name, NAME_LEN);
rfValue[i] = d.value; rfBits[i] = d.bitlength; rfProto[i] = d.protocol;
}
f.close();
}
}
}
uint8_t rfUsedCount() { uint8_t c = 0; for (uint8_t i = 0; i < MAX_RF_SLOTS; i++) if (rfUsed[i]) c++; return c; }
int rfFirstFree() { for (uint8_t i = 0; i < MAX_RF_SLOTS; i++) if (!rfUsed[i]) return i; return -1; }
bool saveRFSlot(uint8_t i, const char* name, unsigned long value, unsigned int bits, unsigned int proto) {
File f = LittleFS.open(rfPath(i), "w");
if (!f) return false;
RFSlotData d; d.value = value; d.bitlength = bits; d.protocol = proto;
memset(d.name, 0, NAME_LEN); strncpy(d.name, name, NAME_LEN - 1);
f.write((uint8_t*)&d, sizeof(d));
f.close();
return true;
}
void buildList(bool esRF) {
if (esRF) refreshRFSlots(); else refreshIRSlots();
listCount = 0;
uint8_t max = esRF ? MAX_RF_SLOTS : MAX_IR_SLOTS;
for (uint8_t i = 0; i < max; i++) {
bool usado = esRF ? rfUsed[i] : irUsed[i];
if (usado) listIds[listCount++] = i;
}
if (listIndex >= listCount) listIndex = listCount ? listCount - 1 : 0;
}
// =====================================================================
// BOTONES
// =====================================================================
const uint8_t bUp = 0, bDown = 1, bOk = 2, bBack = 3;
const uint8_t BTN_PINS[4] = {PIN_UP, PIN_DOWN, PIN_OK, PIN_BACK};
const char* BTN_NAMES[4] = {"ARRIBA", "ABAJO", "ACEPTAR", "VOLVER"};
bool btnLastReading[4] = {HIGH, HIGH, HIGH, HIGH};
bool btnStable[4] = {HIGH, HIGH, HIGH, HIGH};
unsigned long btnLastChange[4] = {0, 0, 0, 0};
unsigned long btnPressedAt[4] = {0, 0, 0, 0};
unsigned long btnNextRepeat[4] = {0, 0, 0, 0};
const unsigned long DEBOUNCE_MS = 30;
bool pressed(uint8_t i) {
bool r = digitalRead(BTN_PINS[i]);
unsigned long now = millis();
if (r != btnLastReading[i]) { btnLastReading[i] = r; btnLastChange[i] = now; }
if ((now - btnLastChange[i]) > DEBOUNCE_MS && r != btnStable[i]) {
btnStable[i] = r;
if (r == LOW) {
btnPressedAt[i] = now;
btnNextRepeat[i] = now + 450;
logMsg("BOTON %-8s (GPIO %2u) PULSADO | pantalla: %s", BTN_NAMES[i], BTN_PINS[i], STATE_NAMES[state]);
return true;
} else {
logMsg("BOTON %-8s (GPIO %2u) SOLTADO | duracion: %lu ms", BTN_NAMES[i], BTN_PINS[i], now - btnPressedAt[i]);
}
}
if (i < 2 && btnStable[i] == LOW && now >= btnNextRepeat[i]) {
btnNextRepeat[i] = now + 140;
return true;
}
return false;
}
// =====================================================================
// UI: helpers
// =====================================================================
bool radarActivo() { return digitalRead(PIN_RADAR) == HIGH; }
// Encabezado estandar: titulo a la izquierda, e indicador de radar (punto
// relleno) a la derecha cuando detecta movimiento, en vez de texto fijo.
void uiHeader(const char* title, const char* right) {
display.clearDisplay();
display.fillRect(0, 0, 128, 11, SSD1306_WHITE);
display.setTextSize(1);
display.setTextColor(SSD1306_BLACK);
display.setCursor(3, 2);
display.print(title);
if (right && right[0]) {
display.setCursor(110 - (int)strlen(right) * 6, 2);
display.print(right);
}
if (radarActivo()) display.fillCircle(122, 5, 3, SSD1306_BLACK);
display.setTextColor(SSD1306_WHITE);
}
void uiFooter(const char* text) {
display.drawFastHLine(0, 54, 128, SSD1306_WHITE);
display.setCursor(0, 56);
display.print(text);
}
void uiCenter(const char* t, int y) {
int w = (int)strlen(t) * 6;
display.setCursor((128 - w) / 2, y);
display.print(t);
}
void uiScrollbar(uint8_t count, uint8_t sel, uint8_t visible) {
if (count <= visible) return;
const int top = 13, h = 40;
display.drawFastVLine(126, top, h, SSD1306_WHITE);
int thumbH = h * visible / count;
if (thumbH < 4) thumbH = 4;
int thumbY = top + (h - thumbH) * sel / (count - 1);
display.fillRect(125, thumbY, 3, thumbH, SSD1306_WHITE);
}
// Iconos 8x8 para el menu principal: 0 IR (rayo), 1 RF (antena), 2 NRF (dos radios), 3 Sistema (engranaje)
void uiIconTop(uint8_t type, int x, int y, uint16_t c) {
switch (type) {
case 0: // IR: rayo simple
display.drawLine(x + 5, y, x + 1, y + 4, c);
display.drawLine(x + 1, y + 4, x + 5, y + 4, c);
display.drawLine(x + 5, y + 4, x + 1, y + 8, c);
break;
case 1: // RF: antena con ondas
display.drawLine(x + 3, y + 2, x + 3, y + 8, c);
display.fillCircle(x + 3, y + 1, 1, c);
display.drawCircle(x + 3, y + 1, 3, c);
break;
case 2: // NRF: dos radios enlazadas (dos circulos pequenos unidos)
display.drawCircle(x + 2, y + 4, 2, c);
display.drawCircle(x + 6, y + 4, 2, c);
display.drawLine(x + 3, y + 4, x + 5, y + 4, c);
break;
default: // Sistema: engranaje simplificado (cuadro + circulo)
display.drawRect(x, y + 1, 8, 6, c);
display.drawCircle(x + 4, y + 4, 2, c);
break;
}
}
// Iconos 8x8 para submenus de accion: 0 Enviar, 1 Aprender, 2 Borrar
void uiIconAction(uint8_t type, int x, int y, uint16_t c) {
switch (type) {
case 0: display.fillTriangle(x, y, x, y + 7, x + 7, y + 3, c); break; // enviar (play)
case 1: display.drawCircle(x + 3, y + 3, 3, c); display.fillCircle(x + 3, y + 3, 1, c); break; // aprender (grabar)
default: display.drawLine(x, y, x + 7, y + 7, c); display.drawLine(x, y + 7, x + 7, y, c); break; // borrar (X)
}
}
// =====================================================================
// UI: menus genericos
// =====================================================================
void drawTopMenu() {
uiHeader("Control Remoto", "");
for (uint8_t i = 0; i < TOP_COUNT; i++) {
int y = 14 + i * 10;
bool sel = (i == topIndex);
if (sel) display.fillRoundRect(2, y - 1, 124, 10, 2, SSD1306_WHITE);
uint16_t c = sel ? SSD1306_BLACK : SSD1306_WHITE;
uiIconTop(i, 6, y - 1, c);
display.setTextColor(c);
display.setCursor(20, y);
display.setTextSize(1);
display.print(TOP_ITEMS[i]);
}
display.setTextColor(SSD1306_WHITE);
uiFooter("OK=elegir");
display.display();
}
void drawSubMenu(const char* title, const char** items, uint8_t count) {
uiHeader(title, "");
for (uint8_t i = 0; i < count; i++) {
int y = 14 + i * 10;
bool sel = (i == subIndex);
if (sel) display.fillRoundRect(2, y - 1, 124, 10, 2, SSD1306_WHITE);
uint16_t c = sel ? SSD1306_BLACK : SSD1306_WHITE;
uiIconAction(i, 6, y - 1, c);
display.setTextColor(c);
display.setCursor(20, y);
display.print(items[i]);
}
display.setTextColor(SSD1306_WHITE);
uiFooter("OK=elegir VOL=atras");
display.display();
}
void drawSlotList(bool esRF, const char* title, const char* hint) {
if (listCount == 0) {
uiHeader(title, "0");
uiCenter("No hay senales", 24);
uiCenter("guardadas", 35);
uiFooter("VOLVER=atras");
display.display();
return;
}
char right[10];
snprintf(right, sizeof(right), "%u/%u", listIndex + 1, listCount);
uiHeader(title, right);
const uint8_t visible = 4;
uint8_t start = (listIndex >= visible) ? listIndex - visible + 1 : 0;
for (uint8_t row = 0; row < visible && (start + row) < listCount; row++) {
uint8_t idx = start + row;
uint8_t id = listIds[idx];
int y = 13 + row * 10;
bool sel = (idx == listIndex);
if (sel) display.fillRect(0, y - 1, 123, 10, SSD1306_WHITE);
display.setTextColor(sel ? SSD1306_BLACK : SSD1306_WHITE);
display.setCursor(4, y);
display.print(esRF ? rfName[id] : irName[id]);
}
display.setTextColor(SSD1306_WHITE);
uiScrollbar(listCount, listIndex, visible);
uiFooter(hint);
display.display();
}
void drawLearnIR() {
uiHeader("Aprender IR", "");
if (!learnHasSignal) {
uiCenter("Apunta el control", 20);
uiCenter("y pulsa un boton", 30);
display.drawRect(14, 40, 100, 8, SSD1306_WHITE);
int p = (millis() / 30) % 180; if (p >= 90) p = 179 - p;
display.fillRect(16 + p, 42, 8, 4, SSD1306_WHITE);
uiFooter("VOLVER=cancelar");
} else {
display.setCursor(0, 18); display.print("Tipo: "); display.print(pendingProto);
display.setCursor(0, 30); display.print("Pulsos: "); display.print(pendingLen);
uiFooter("OK=guardar VOL=desc.");
}
display.display();
}
void drawLearnRF() {
uiHeader("Aprender RF", "");
if (!pendingRFHas) {
uiCenter("Pulse el control", 20);
uiCenter("315/433 MHz...", 30);
display.drawRect(14, 40, 100, 8, SSD1306_WHITE);
int p = (millis() / 30) % 180; if (p >= 90) p = 179 - p;
display.fillRect(16 + p, 42, 8, 4, SSD1306_WHITE);
uiFooter("VOLVER=cancelar");
} else {
display.setCursor(0, 16); display.print("Valor: "); display.print(pendingRFValue);
display.setCursor(0, 28); display.print("Bits: "); display.print(pendingRFBits);
display.setCursor(0, 40); display.print("Proto: "); display.print(pendingRFProto);
uiFooter("OK=guardar VOL=desc.");
}
display.display();
}
void drawConfirm(const char* nombre) {
uiHeader("Borrar", "");
uiCenter("Borrar esta senal?", 22);
uiCenter(nombre, 34);
uiFooter("OK=si VOL=no");
display.display();
}
void drawInfo() {
uiHeader("Informacion", "");
char b[28];
snprintf(b, sizeof(b), "IR: %u/%u RF: %u/%u", irUsedCount(), MAX_IR_SLOTS, rfUsedCount(), MAX_RF_SLOTS);
display.setCursor(0, 16); display.print(b);
snprintf(b, sizeof(b), "Flash: %u/%u KB", (unsigned)(LittleFS.usedBytes() / 1024), (unsigned)(LittleFS.totalBytes() / 1024));
display.setCursor(0, 28); display.print(b);
snprintf(b, sizeof(b), "Radar: %s", radarActivo() ? "movimiento" : "en reposo");
display.setCursor(0, 40); display.print(b);
uiFooter("VOLVER=atras");
display.display();
}
void redraw() {
switch (state) {
case ST_TOP: drawTopMenu(); break;
case ST_SUB_IR: drawSubMenu("IR", SUBIR_ITEMS, SUBIR_COUNT); break;
case ST_SUB_RF: drawSubMenu("RF 315/433", SUBRF_ITEMS, SUBRF_COUNT); break;
case ST_SUB_NRF: drawSubMenu("NRF 2.4GHz", SUBNRF_ITEMS, SUBNRF_COUNT); break;
case ST_IR_SEND_LIST: drawSlotList(false, "Enviar IR", "OK=enviar VOL=atras"); break;
case ST_IR_DELETE_LIST: drawSlotList(false, "Borrar IR", "OK=borrar VOL=atras"); break;
case ST_IR_LEARN: drawLearnIR(); break;
case ST_IR_CONFIRM_DELETE: drawConfirm(irName[pendingDeleteId]); break;
case ST_RF_SEND_LIST: drawSlotList(true, "Enviar RF", "OK=enviar VOL=atras"); break;
case ST_RF_DELETE_LIST: drawSlotList(true, "Borrar RF", "OK=borrar VOL=atras"); break;
case ST_RF_LEARN: drawLearnRF(); break;
case ST_RF_CONFIRM_DELETE: drawConfirm(rfName[pendingDeleteId]); break;
case ST_INFO: drawInfo(); break;
default: break;
}
}
void showMessage(const char* l1, const char* l2, State next, unsigned long ms = 1300) {
uiHeader("Control Remoto", "");
uiCenter(l1, 26);
uiCenter(l2, 38);
display.display();
logMsg("MENSAJE: %s %s", l1, l2);
afterMessage = next;
messageUntil = millis() + ms;
setState(ST_MESSAGE);
}
// =====================================================================
// Splash de inicio: Pac-Man cruza la pantalla comiendo puntos
// =====================================================================
void drawPacman(int cx, int cy, int r, bool bocaAbierta, int dir) {
display.fillCircle(cx, cy, r, SSD1306_WHITE);
if (bocaAbierta) {
int puntaX = cx + dir * (r + 3);
display.fillTriangle(cx, cy, puntaX, cy - r, puntaX, cy + r, SSD1306_BLACK);
}
}
void showSplash() {
const int cy = 28;
const int r = 7;
const int pasoX = 6;
int puntosX[14];
int numPuntos = 0;
for (int x = 14; x <= 112; x += 10) puntosX[numPuntos++] = x;
bool comido[14] = {false};
bool boca = true;
for (int cx = 6; cx <= 118; cx += pasoX) {
display.clearDisplay();
for (int i = 0; i < numPuntos; i++) {
if (!comido[i]) {
if (puntosX[i] < cx + r) comido[i] = true;
else display.fillCircle(puntosX[i], cy, 1, SSD1306_WHITE);
}
}
drawPacman(cx, cy, r, boca, 1);
boca = !boca;
uiCenter("CONTROL REMOTO", 48);
display.display();
delay(45);
}
display.clearDisplay();
display.setTextSize(2);
display.setTextColor(SSD1306_WHITE);
display.setCursor(4, 10);
display.print("CONTROL");
display.setTextSize(1);
uiCenter("IR + RF + NRF24", 34);
display.drawRect(14, 48, 100, 7, SSD1306_WHITE);
display.display();
for (int i = 0; i <= 96; i += 4) {
display.fillRect(16, 50, i, 3, SSD1306_WHITE);
display.display();
delay(10);
}
delay(200);
}
// =====================================================================
// IR
// =====================================================================
void startLearnIR() {
learnHasSignal = false; pendingLen = 0;
irrecv.resume();
logMsg("APRENDER IR: esperando senal en GPIO %u...", PIN_IR_RX);
drawLearnIR();
}
void captureIRIfAny() {
if (!irrecv.decode(&irResults)) return;
uint16_t n = getCorrectedRawLength(&irResults);
if (n >= 12) {
uint16_t* raw = resultToRawArray(&irResults);
if (raw != nullptr) {
pendingLen = (n > MAX_RAW) ? MAX_RAW : n;
memcpy(pendingRaw, raw, pendingLen * sizeof(uint16_t));
delete[] raw;
pendingProto = typeToString(irResults.decode_type);
learnHasSignal = true;
logMsg("IR CAPTURADA: tipo=%s pulsos=%u", pendingProto.c_str(), n);
drawLearnIR();
}
}
irrecv.resume();
}
void sendIRSlot(uint8_t id) {
uint16_t len = loadIRSlot(id, sendBuf);
if (len == 0) { showMessage("Error al leer", "la senal", ST_IR_SEND_LIST); return; }
uiHeader("Enviando IR", ""); uiCenter(irName[id], 26); display.display();
logMsg("ENVIAR IR: ranura %u '%s' (%u pulsos)", id, irName[id], len);
irrecv.disableIRIn();
irsend.sendRaw(sendBuf, len, IR_FREQ_KHZ);
irrecv.enableIRIn();
showMessage("IR enviada:", irName[id], ST_IR_SEND_LIST, 700);
}
// =====================================================================
// RF 315/433
// =====================================================================
void startLearnRF() {
pendingRFHas = false;
rcSwitch.enableReceive(digitalPinToInterrupt(PIN_RF_RX));
logMsg("APRENDER RF: esperando senal en GPIO %u...", PIN_RF_RX);
drawLearnRF();
}
void captureRFIfAny() {
if (!rcSwitch.available()) return;
pendingRFValue = rcSwitch.getReceivedValue();
pendingRFBits = rcSwitch.getReceivedBitlength();
pendingRFProto = rcSwitch.getReceivedProtocol();
pendingRFHas = true;
logMsg("RF CAPTURADA: valor=%lu bits=%u proto=%u", pendingRFValue, pendingRFBits, pendingRFProto);
rcSwitch.resetAvailable();
drawLearnRF();
}
void sendRFSlot(uint8_t id) {
uiHeader("Enviando RF", ""); uiCenter(rfName[id], 26); display.display();
logMsg("ENVIAR RF: ranura %u '%s' valor=%lu", id, rfName[id], rfValue[id]);
rcSwitch.enableTransmit(PIN_RF_TX);
rcSwitch.setProtocol(rfProto[id]);
rcSwitch.send(rfValue[id], rfBits[id]);
showMessage("RF enviada:", rfName[id], ST_RF_SEND_LIST, 700);
}
// =====================================================================
// NRF24 - prueba de enlace
// =====================================================================
void probarNRF() {
uiHeader("Probando NRF", ""); uiCenter("Enviando...", 28); display.display();
logMsg("NRF: iniciando prueba de enlace");
pinMode(PIN_NRF1_CSN, OUTPUT); digitalWrite(PIN_NRF1_CSN, HIGH);
pinMode(PIN_NRF2_CSN, OUTPUT); digitalWrite(PIN_NRF2_CSN, HIGH);
radio1.begin(); radio1.setPALevel(RF24_PA_LOW);
radio1.openWritingPipe(nrfAddr); radio1.stopListening();
radio2.begin(); radio2.setPALevel(RF24_PA_LOW);
radio2.openReadingPipe(1, nrfAddr); radio2.startListening();
bool ok = false;
for (int intento = 0; intento < 5; intento++) {
nrfCounter++;
bool enviado = radio1.write(&nrfCounter, sizeof(nrfCounter));
delay(20);
if (enviado && radio2.available()) {
uint32_t recibido = 0;
radio2.read(&recibido, sizeof(recibido));
if (recibido == nrfCounter) { ok = true; break; }
}
delay(150);
}
logMsg("NRF: resultado -> %s", ok ? "OK" : "SIN RESPUESTA");
showMessage(ok ? "NRF OK" : "NRF sin respuesta", ok ? "Enlace funcionando" : "Revisar cableado", menuReturn, 1600);
}
// =====================================================================
// SETUP / LOOP
// =====================================================================
void setup() {
Serial.begin(115200);
delay(200);
Serial.println("\n=== Control Remoto Universal (IR + RF + NRF24) ===");
pinMode(PIN_UP, INPUT_PULLUP);
pinMode(PIN_DOWN, INPUT_PULLUP);
pinMode(PIN_OK, INPUT_PULLUP);
pinMode(PIN_BACK, INPUT); // resistencia externa de 10k a 3V3
pinMode(PIN_RADAR, INPUT);
Wire.begin(PIN_SDA, PIN_SCL);
Wire.setClock(400000);
if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
logMsg("ERROR: OLED no responde en 0x%02X", OLED_ADDR);
for (;;) delay(1000);
}
display.setRotation(OLED_ROTATION);
logMsg("OLED OK (0x%02X)", OLED_ADDR);
showSplash();
if (!LittleFS.begin(true)) {
logMsg("ERROR: LittleFS no pudo iniciar");
} else {
logMsg("LittleFS OK: %u/%u KB usados", (unsigned)(LittleFS.usedBytes() / 1024), (unsigned)(LittleFS.totalBytes() / 1024));
}
irsend.begin();
irrecv.enableIRIn();
logMsg("IR listo: RX=GPIO %u TX=GPIO %u", PIN_IR_RX, PIN_IR_TX);
refreshIRSlots();
refreshRFSlots();
logMsg("Senales IR: %u/%u | RF: %u/%u", irUsedCount(), MAX_IR_SLOTS, rfUsedCount(), MAX_RF_SLOTS);
setState(ST_TOP);
drawTopMenu();
}
void loop() {
bool evUp = pressed(bUp);
bool evDown = pressed(bDown);
bool evOk = pressed(bOk);
bool evBack = pressed(bBack);
static bool lastRadar = radarActivo();
bool currentRadar = radarActivo();
if (currentRadar != lastRadar) { lastRadar = currentRadar; if (state != ST_MESSAGE && state != ST_NRF_TEST) redraw(); }
switch (state) {
// ---------------- Menu superior ----------------
case ST_TOP:
// Refresca cada 300 ms para que el punto de radar se vea en vivo,
// aunque no se este presionando ningun boton.
if (evUp) { topIndex = (topIndex + TOP_COUNT - 1) % TOP_COUNT; drawTopMenu(); }
if (evDown) { topIndex = (topIndex + 1) % TOP_COUNT; drawTopMenu(); }
if (evOk) {
subIndex = 0;
logMsg("MENU: abrir categoria '%s'", TOP_ITEMS[topIndex]);
if (topIndex == 0) { setState(ST_SUB_IR); redraw(); }
else if (topIndex == 1) { setState(ST_SUB_RF); redraw(); }
else if (topIndex == 2) { setState(ST_SUB_NRF); redraw(); }
else { menuReturn = ST_TOP; setState(ST_INFO); redraw(); } // Sistema -> Informacion directo
}
break;
// ---------------- Submenu IR ----------------
case ST_SUB_IR:
if (evUp) { subIndex = (subIndex + SUBIR_COUNT - 1) % SUBIR_COUNT; redraw(); }
if (evDown) { subIndex = (subIndex + 1) % SUBIR_COUNT; redraw(); }
if (evOk) {
menuReturn = ST_SUB_IR;
listIndex = 0;
if (subIndex == 0) { buildList(false); setState(ST_IR_SEND_LIST); redraw(); }
else if (subIndex == 1) {
refreshIRSlots();
if (irFirstFree() < 0) showMessage("Memoria IR", "llena", ST_SUB_IR);
else { setState(ST_IR_LEARN); startLearnIR(); }
} else { buildList(false); setState(ST_IR_DELETE_LIST); redraw(); }
}
if (evBack) { setState(ST_TOP); redraw(); }
break;
// ---------------- Submenu RF ----------------
case ST_SUB_RF:
if (evUp) { subIndex = (subIndex + SUBRF_COUNT - 1) % SUBRF_COUNT; redraw(); }
if (evDown) { subIndex = (subIndex + 1) % SUBRF_COUNT; redraw(); }
if (evOk) {
menuReturn = ST_SUB_RF;
listIndex = 0;
if (subIndex == 0) { buildList(true); setState(ST_RF_SEND_LIST); redraw(); }
else if (subIndex == 1) {
refreshRFSlots();
if (rfFirstFree() < 0) showMessage("Memoria RF", "llena", ST_SUB_RF);
else { setState(ST_RF_LEARN); startLearnRF(); }
} else { buildList(true); setState(ST_RF_DELETE_LIST); redraw(); }
}
if (evBack) { setState(ST_TOP); redraw(); }
break;
// ---------------- Submenu NRF ----------------
case ST_SUB_NRF:
// (un solo item por ahora; up/down no hacen falta pero no estorban)
if (evOk) {
menuReturn = ST_SUB_NRF;
setState(ST_NRF_TEST);
probarNRF();
}
if (evBack) { setState(ST_TOP); redraw(); }
break;
// ---------------- IR: listas y acciones ----------------
case ST_IR_SEND_LIST:
if (evUp && listCount) { listIndex = (listIndex + listCount - 1) % listCount; redraw(); }
if (evDown && listCount) { listIndex = (listIndex + 1) % listCount; redraw(); }
if (evOk && listCount) { sendIRSlot(listIds[listIndex]); }
if (evBack) { setState(menuReturn); redraw(); }
break;
case ST_IR_LEARN:
captureIRIfAny();
if (!learnHasSignal && millis() - lastAnim > 60) { lastAnim = millis(); drawLearnIR(); }
if (evOk && learnHasSignal) {
int id = irFirstFree();
if (id >= 0) {
char name[NAME_LEN]; snprintf(name, NAME_LEN, "IR %d", id + 1);
bool ok = saveIRSlot(id, name, pendingRaw, pendingLen);
refreshIRSlots();
showMessage(ok ? "Guardada:" : "Error al guardar", ok ? name : "", menuReturn);
}
}
if (evBack) { setState(menuReturn); redraw(); }
break;
case ST_IR_DELETE_LIST:
if (evUp && listCount) { listIndex = (listIndex + listCount - 1) % listCount; redraw(); }
if (evDown && listCount) { listIndex = (listIndex + 1) % listCount; redraw(); }
if (evOk && listCount) { pendingDeleteId = listIds[listIndex]; setState(ST_IR_CONFIRM_DELETE); redraw(); }
if (evBack) { setState(menuReturn); redraw(); }
break;
case ST_IR_CONFIRM_DELETE:
if (evOk) {
char n[NAME_LEN]; strncpy(n, irName[pendingDeleteId], NAME_LEN);
bool ok = LittleFS.remove(irPath(pendingDeleteId));
buildList(false);
showMessage(ok ? "Borrada:" : "Error", ok ? n : "", ST_IR_DELETE_LIST, 900);
}
if (evBack) { setState(ST_IR_DELETE_LIST); redraw(); }
break;
// ---------------- RF: listas y acciones ----------------
case ST_RF_SEND_LIST:
if (evUp && listCount) { listIndex = (listIndex + listCount - 1) % listCount; redraw(); }
if (evDown && listCount) { listIndex = (listIndex + 1) % listCount; redraw(); }
if (evOk && listCount) { sendRFSlot(listIds[listIndex]); }
if (evBack) { setState(menuReturn); redraw(); }
break;
case ST_RF_LEARN:
captureRFIfAny();
if (!pendingRFHas && millis() - lastAnim > 60) { lastAnim = millis(); drawLearnRF(); }
if (evOk && pendingRFHas) {
int id = rfFirstFree();
if (id >= 0) {
char name[NAME_LEN]; snprintf(name, NAME_LEN, "RF %d", id + 1);
bool ok = saveRFSlot(id, name, pendingRFValue, pendingRFBits, pendingRFProto);
refreshRFSlots();
rcSwitch.disableReceive();
showMessage(ok ? "Guardada:" : "Error al guardar", ok ? name : "", menuReturn);
}
}
if (evBack) { rcSwitch.disableReceive(); setState(menuReturn); redraw(); }
break;
case ST_RF_DELETE_LIST:
if (evUp && listCount) { listIndex = (listIndex + listCount - 1) % listCount; redraw(); }
if (evDown && listCount) { listIndex = (listIndex + 1) % listCount; redraw(); }
if (evOk && listCount) { pendingDeleteId = listIds[listIndex]; setState(ST_RF_CONFIRM_DELETE); redraw(); }
if (evBack) { setState(menuReturn); redraw(); }
break;
case ST_RF_CONFIRM_DELETE:
if (evOk) {
char n[NAME_LEN]; strncpy(n, rfName[pendingDeleteId], NAME_LEN);
bool ok = LittleFS.remove(rfPath(pendingDeleteId));
buildList(true);
showMessage(ok ? "Borrada:" : "Error", ok ? n : "", ST_RF_DELETE_LIST, 900);
}
if (evBack) { setState(ST_RF_DELETE_LIST); redraw(); }
break;
// ---------------- Info / mensaje ----------------
case ST_INFO:
if (evBack || evOk) { setState(menuReturn); redraw(); }
break;
case ST_MESSAGE:
if (millis() >= messageUntil) {
setState(afterMessage);
if (state == ST_IR_SEND_LIST || state == ST_IR_DELETE_LIST) buildList(false);
if (state == ST_RF_SEND_LIST || state == ST_RF_DELETE_LIST) buildList(true);
redraw();
}
break;
default: break;
}
}
// Cableado confirmado: RF RX 34, radar 36, atrás 35, NRF2 CE 13 / CSN 26.Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace, diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.




