Semáforo Bluetooth fácil con tu móvil (Chisme 27 del Lince)
Este proyecto de semáforo Bluetooth es el semáforo del Lince, el Chisme 27: un módulo semáforo de tres luces conectado a una placa Heltec LoRa 32 que enciendes desde el móvil. Pulsas rojo, amarillo o verde en una página web y la luz se enciende en la placa, mientras un semáforo dibujado en la pantalla del móvil la copia. También hay un modo automático que hace el ciclo completo solo. Todo funciona desde el navegador, sin instalar ninguna app y sin emparejar nada.
Es el tercer Chisme con la Heltec, y el más sencillo a propósito: no hay sensores ni cálculos, solo tres LEDs y un móvil. Por eso es perfecto para practicar. En el Chisme 25 mandamos texto del móvil a la pantalla, y en el Chisme 26 hicimos lo contrario: la placa medía y el móvil recibía. Con este semáforo Bluetooth unimos las dos cosas: el móvil da órdenes y la placa responde.
Antes de empezar: en este Chisme no repetimos la instalación del Arduino IDE, del soporte ESP32 ni de las librerías de la pantalla. Todo eso está en los pasos 1 a 4 del Chisme 25 (la guía oficial del soporte ESP32 está en la documentación de Arduino-ESP32). Si la placa ya te funcionó con el letrero del Lince, no hay que instalar nada nuevo: este semáforo Bluetooth usa las mismas librerías.

Semáforo Bluetooth: qué vamos a construir
La página web del semáforo Bluetooth tiene:
- Un botón para conectar la Heltec por Bluetooth.
- Un semáforo dibujado con sus tres luces, que copia lo que está encendido en la placa.
- Tres botones grandes: Rojo, Amarillo y Verde.
- Un botón Semáforo automático, que hace el ciclo verde, amarillo y rojo por sí solo.
- Un botón Apagar.
La pantalla OLED de la placa muestra, en letra grande, el color que está encendido.
Lo que necesitas para tu semáforo Bluetooth
- La Heltec LoRa 32 (o TTGO LoRa32) de los Chismes anteriores, con el Arduino IDE listo.
- Un módulo semáforo de 4 pines, de los que vienen en las cajas de 37 sensores. Tiene las letras R, Y, G y GND (rojo, amarillo, verde y tierra).
- Cuatro cables dupont hembra-hembra o macho-hembra, según los pines de tu placa.
- El cable USB de datos de siempre.
- Un móvil Android con Chrome, con Bluetooth y ubicación encendidos. En iPhone, el navegador Bluefy.
Paso 1. Conecta el módulo semáforo a la Heltec
Hazlo con la placa desconectada del USB. El módulo tiene cuatro conexiones:
| Pin del módulo | Se conecta a la Heltec |
|---|---|
| R (rojo) | GPIO 13 |
| Y (amarillo) | GPIO 4 |
| G (verde) | GPIO 23 |
| GND | GND |

Tres cosas que evitan casi todos los problemas:
- Mira las letras impresas en tu módulo. El orden de los pines cambia de un módulo a otro, así que no te fíes del orden de las fotos.
- Conecta los cables directamente, sin breadboard. Si usas una, comprueba que el pin del módulo y su cable estén en la misma fila y en el mismo lado del canal central. Los contactos flojos de las breadboards son la causa más frecuente de una luz que «no funciona».
- Si tu módulo no lleva resistencias, pon una de 220 Ω en cada color. Los módulos de las cajas de sensores ya las llevan.
Los pines 13, 4 y 23 son los que hemos probado en la TTGO LoRa32 v1.3 (la de la pantalla en los pines 21 y 22). Si en tu placa no los ves o los usa otra cosa, cambia los números en PIN_ROJO, PIN_AMARILLO y PIN_VERDE del código.
Paso 2. Prueba las luces antes de nada
Antes de meterte con el Bluetooth, comprueba que las tres luces se encienden. Sube este programa de prueba (sección «Código 3», al final de esta página): enciende una luz cada segundo, en orden.
- Archivo → Nuevo sketch, borra todo y pega el Código 3.
- Elige la placa ESP32 Dev Module y el puerto de tu Heltec (paso 6 del Chisme 25).
- Pulsa Subir. Si se queda en
Connecting…, mantén pulsado el botón PRG hasta que empiece a escribir. - Mira el semáforo: debe encenderse rojo, amarillo y verde, de uno en uno.
Si alguna luz no se enciende, no sigas: el problema es el cableado y es mucho más fácil encontrarlo con este programa pequeño. Mira la sección «Si tu semáforo Bluetooth falla».

Paso 3. Pega el programa del semáforo y súbelo
- Archivo → Nuevo sketch y borra todo lo que haya.
- Copia el programa de la placa (sección «Código 1») y pégalo. Guarda el sketch como
chisme27_heltec_semaforo. - Comprueba que la placa elegida es ESP32 Dev Module. No elijas «Heltec WiFi LoRa 32», aunque tu placa lo sea.
- Pulsa Subir.
- Mira la pantalla de la Heltec: debe poner «Bluetooth: esperando» y, debajo, «APAGADO» en letra grande.
La primera vez, la compilación tarda uno o dos minutos porque el Bluetooth es una librería grande. Es normal.
Si la pantalla se queda en negro, son los pines de la OLED: cambia OLED_SDA, OLED_SCL y OLED_RST como te explicamos en el paso 5 del Chisme 25.
Paso 4. Abre la página en el móvil
Tienes dos opciones:
- Usar la nuestra: abre https://montesteam.org/app/chisme-27-heltec-semaforo.html
- Tener la tuya: copia el código de la página (sección «Código 2»), guárdalo como
chisme-27-heltec-semaforo.htmly súbelo a un sitio con https. Conhttp://o desde un archivo local, el Bluetooth del navegador no funciona.
Paso 5. Conecta la Heltec
- En el móvil, con Bluetooth y ubicación encendidos, abre la página en Chrome.
- Pulsa Conectar Heltec y elige Heltec-Lince.
- Tras los mensajes de «Conectando…» y «Activando notificaciones…» verás «Conectado a Heltec-Lince. ¡Pulsa una luz!». Los botones se activan.
Paso 6. Juega con el semáforo Bluetooth
- Pulsa Rojo: se enciende la luz roja de la placa, la pantalla OLED pone «ROJO» y el semáforo del móvil se pone rojo.
- Prueba con Amarillo, Verde y Apagar.
- Pulsa Semáforo automático: verde durante 5 segundos, amarillo durante 2 y rojo durante 5, otra vez y otra vez.
- Mientras está en automático, pulsa un color: el automático se detiene y manda lo que tú has elegido.

Fíjate en una cosa: el semáforo que ves en el móvil no se enciende cuando tú pulsas el botón, sino cuando la placa avisa de que lo ha hecho. Así sabes que la orden ha llegado de verdad. Si desenchufas la placa, el semáforo del móvil deja de cambiar.
Cómo funciona el semáforo Bluetooth
Las órdenes son palabras
El móvil manda a la placa una palabra por línea, terminada en salto de línea:
| Orden | Qué hace |
|---|---|
ROJO | Enciende solo el rojo |
AMARILLO | Enciende solo el amarillo |
VERDE | Enciende solo el verde |
APAGAR | Apaga las tres luces |
SECUENCIA | Arranca el semáforo automático |
La palabra más larga, AMARILLO con su salto de línea, ocupa 9 bytes, así que cabe en un solo envío de Bluetooth (el límite es de 20 bytes).
La placa no actúa dentro del Bluetooth
Cuando llegan datos, el Bluetooth ejecuta la función onWrite. Esa función solo junta las letras y, cuando llega el salto de línea, levanta una bandera (hayLinea). Quien ejecuta la orden es el loop(), que revisa la bandera continuamente. Es una regla de oro con el ESP32: lo que pasa dentro del Bluetooth debe ser rápido, y el trabajo (encender luces, pintar la pantalla) se hace fuera.
Solo una luz a la vez
La función luz() recibe un nombre y hace tres cosas en una sola línea de código por color:
digitalWrite(PIN_ROJO, nombre == "ROJO");
Si el nombre es «ROJO», la condición es verdad y el pin se pone en alto (luz encendida); si no, queda en bajo. Con las tres líneas, siempre hay como mucho una luz encendida. Después, luz() escribe el nombre en la pantalla y se lo cuenta al móvil.
La placa confirma lo que hace
Cada vez que cambia una luz, la placa envía OK: y el nombre (por ejemplo, OK:ROJO). La página lo recibe y dibuja el semáforo. Por eso, en el modo automático, la página no tiene que llevar la cuenta del tiempo: se limita a copiar lo que la placa va avisando.
El semáforo automático no usa delay()
El automático se hace con la función millis(), que da el tiempo transcurrido desde que se encendió la placa. En cada vuelta del loop() se mira si ya han pasado los segundos de la luz actual:
- Verde: 5 segundos.
- Amarillo: 2 segundos.
- Rojo: 5 segundos.
Si usáramos delay(5000), la placa se quedaría «dormida» cinco segundos y no atendería las órdenes del móvil. Con millis(), el loop() sigue girando y el semáforo obedece en cualquier momento.
Una orden manual para el automático
Cuando llega ROJO, AMARILLO, VERDE o APAGAR, el programa pone secuencia = false. Esa es la razón de que el automático se detenga al pulsar un color.
La conexión de la página
La parte de Bluetooth de la página (con sus reintentos y su reconexión automática) es la misma de los Chismes 21 a 26. Es la misma conexión que has usado ya, con una función distinta: aquí mandamos órdenes y recibimos confirmaciones.
Retos para clase con el semáforo Bluetooth
- Cambia los tiempos: en el programa,
DURACIONtiene los milisegundos de cada luz. Haz que el verde dure 10 segundos y el rojo 3. - Semáforo estropeado: añade una orden
INTERMITENTEque haga parpadear el amarillo. Necesitarás una variable nueva y otro bloque conmillis(). - Paso de peatones: haz un ciclo con verde largo, amarillo corto y rojo largo, y comprueba cuánto tarda cada vuelta.
- Un nombre nuevo: cambia
BLEDevice::init("Heltec-Lince")por el nombre de tu clase y comprueba que aparece en la lista de Bluetooth. - Botón de emergencia: haz que el botón PRG de la placa (pin 0) ponga el semáforo en rojo. Es la vuelta al Chisme 25, donde ya usamos ese botón.
- Reto avanzado: añade a la página un botón «Parpadeo» que mande
ROJOyAPAGARalternativamente cada medio segundo. ¿Dónde es mejor hacerlo, en la página o en la placa?
Si tu semáforo Bluetooth falla
- La pantalla de la placa dice «VERDE» pero la luz no se enciende: el Bluetooth y el programa funcionan. El fallo está entre la placa y el módulo. Sube el Código 3 (la prueba de luces) y revisa el cableado.
- Una luz funciona y otra no: cambia el cable de esa luz por otro, o conéctalo a otro agujero. Es muy frecuente que un cable dupont tenga el hilo interior roto sin que se note. Si tocas el pin de la placa con el extremo del cable y la luz sí se enciende, el cable o el contacto son el problema.
- Con breadboard no va, y sin ella sí: los contactos de la breadboard fallan mucho. Conecta los cables directamente, o comprueba la fila y el lado del canal central.
- Las luces brillan muy poco: mira que el módulo esté conectado a GND y que las luces vayan a los pines de señal, no a 3V3.
- La pantalla se queda en negro: son los pines de la OLED. Mira el paso 5 del Chisme 25. Algunas Heltec V1/V2 tienen la pantalla en los pines 4, 15 y 16: si es tu caso, el amarillo no puede ir al pin 4, y tendrás que moverlo a otro libre y cambiar
PIN_AMARILLO. - La placa no arranca con un cable puesto: desconecta el cable de señal, sube el programa y vuelve a conectarlo. Algunos pines tienen funciones especiales al arrancar.
- Los botones de la página no se activan: no estás conectado todavía. Pulsa Conectar Heltec.
- Conecta, pero las luces no responden: comprueba que has subido el programa del Chisme 27 y no el de otro. La placa del 26, por ejemplo, se conecta pero no entiende órdenes.
- Problemas de Bluetooth (no aparece «Heltec-Lince»,
NotSupportedError,NetworkError…): son los mismos del Chisme 25. Mira su sección «Si tu Heltec Bluetooth falla». - En iPhone no conecta: abre la página con Bluefy.
- La página debe abrirse con https://.
Preguntas frecuentes
¿Qué es un semáforo Bluetooth?
Es un semáforo de LEDs que se controla de forma inalámbrica desde el móvil. En este Chisme, una placa Heltec con Bluetooth BLE recibe las órdenes de una página web y enciende la luz que le pides.
¿Necesito una app para el móvil?
No. Para usar el semáforo Bluetooth basta con abrir la página en Chrome (Android) o en el navegador Bluefy (iPhone). La página usa Web Bluetooth, que viene incluido en el navegador.
¿Sirve cualquier módulo semáforo?
Sirve cualquiera de tres luces con una señal por color y un pin de tierra, como el de las cajas de 37 sensores. Si el tuyo no lleva resistencias, pon una de 220 Ω en cada luz.
¿Puedo usar otros pines distintos de 13, 4 y 23?
Sí, cualquier pin libre de la placa. Cambia PIN_ROJO, PIN_AMARILLO y PIN_VERDE en el código. Evita los de la pantalla OLED y los de la radio LoRa, y mira que tu placa los saque al conector.
¿Por qué el semáforo del móvil espera la respuesta de la placa?
Para que siempre muestre la verdad. Si el semáforo del móvil se encendiera al pulsar el botón, podría enseñar una luz que la placa nunca ha encendido porque la orden se perdió. Así, lo que ves en el móvil es lo que ocurre de verdad.
Qué hemos aprendido
Con este semáforo Bluetooth hemos visto cómo un móvil da órdenes a una placa y cómo la placa responde, con lo que la comunicación va en los dos sentidos. Hemos aprendido que las órdenes pueden ser palabras sencillas y que la placa debe confirmar lo que hace. Hemos comprobado por qué un programa de placa no debe usar delay() cuando tiene que obedecer en cualquier momento, y que millis() permite llevar la cuenta del tiempo sin dormirse. Y hemos aprendido una lección muy práctica: cuando una luz no se enciende, un programa de prueba pequeño y un cable cambiado resuelven casi cualquier misterio.
¿Quieres seguir con la Heltec? Mira el curso gratuito de micro:bit de MonteSTEAM y el resto de los Chismes del Lince. Si quieres entender mejor cómo funciona la función millis(), la referencia de Arduino lo explica con ejemplos, y la API Web Bluetooth cuenta la parte del navegador.
Código 1: programa de la placa (Arduino IDE)
/*
Chisme 27 · El semáforo del Lince (Heltec / TTGO LoRa32 con ESP32)
Controlas desde el móvil, por Bluetooth, un módulo semáforo de 4 pines.
Librerías: Adafruit SSD1306 y Adafruit GFX (las del Chisme 25).
Placa en el Arduino IDE: "ESP32 Dev Module" (aunque tu placa sea una Heltec).
CABLEADO (con la placa apagada). El módulo trae las letras impresas:
R (rojo) -> GPIO 13
Y (amarillo) -> GPIO 4
G (verde) -> GPIO 23
GND -> GND
Si tu módulo no lleva resistencias, pon una de 220 ohmios en cada color.
Órdenes que entiende (una línea cada una):
ROJO AMARILLO VERDE APAGAR SECUENCIA
*/
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define OLED_SDA 21
#define OLED_SCL 22
#define OLED_RST 16 // pon -1 si tu placa no tiene reset de pantalla
#define OLED_ADDR 0x3C
#define PIN_ROJO 13
#define PIN_AMARILLO 4
#define PIN_VERDE 23
#define SERVICE_UUID "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
#define UUID_RX "6e400002-b5a3-f393-e0a9-e50e24dcca9e" // el móvil escribe aquí
#define UUID_TX "6e400003-b5a3-f393-e0a9-e50e24dcca9e" // la placa avisa por aquí
Adafruit_SSD1306 display(128, 64, &Wire, OLED_RST);
BLECharacteristic *tx = nullptr;
volatile bool conectado = false;
String entrada = "";
String pendiente = "";
volatile bool hayLinea = false;
bool secuencia = false; // true: el semáforo funciona solo
int paso = 0; // 0 verde, 1 amarillo, 2 rojo
unsigned long cambioEn = 0;
String actual = "APAGADO"; // lo que se ve en la pantalla
const unsigned long DURACION[3] = {5000, 2000, 5000};
void enviar(const String &m) {
if (conectado && tx) {
String s = m + "\n";
tx->setValue((uint8_t *)s.c_str(), s.length() > 20 ? 20 : s.length());
tx->notify();
}
}
class AlConectar : public BLEServerCallbacks {
void onConnect(BLEServer *s) { conectado = true; }
void onDisconnect(BLEServer *s) {
conectado = false;
BLEDevice::startAdvertising(); // vuelve a ser visible para reconectar
}
};
class AlRecibir : public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic *c) {
String v = c->getValue().c_str();
for (size_t i = 0; i < v.length(); i++) {
char ch = v[i];
if (ch == '\n') { pendiente = entrada; entrada = ""; hayLinea = true; }
else if (ch != '\r' && entrada.length() < 30) entrada += ch;
}
}
};
void pintar(const char *texto) {
display.clearDisplay();
display.setTextSize(1);
display.setCursor(0, 0);
display.print(conectado ? "Bluetooth: conectado" : "Bluetooth: esperando");
display.setTextSize(2);
display.setCursor(0, 32);
display.print(texto);
display.display();
}
/* Enciende una sola luz (o ninguna) y lo cuenta a la pantalla y al móvil */
void luz(const String &nombre) {
digitalWrite(PIN_ROJO, nombre == "ROJO");
digitalWrite(PIN_AMARILLO, nombre == "AMARILLO");
digitalWrite(PIN_VERDE, nombre == "VERDE");
actual = (nombre == "APAGAR") ? String("APAGADO") : nombre;
pintar(actual.c_str());
enviar("OK:" + actual);
}
void orden(const String &t) {
Serial.println("Orden: " + t);
if (t == "SECUENCIA") {
secuencia = true; paso = 0; cambioEn = millis();
luz("VERDE");
enviar("OK:SECUENCIA");
} else if (t == "ROJO" || t == "AMARILLO" || t == "VERDE" || t == "APAGAR") {
secuencia = false; // una orden manual para la secuencia
luz(t);
}
}
void setup() {
Serial.begin(115200);
pinMode(PIN_ROJO, OUTPUT);
pinMode(PIN_AMARILLO, OUTPUT);
pinMode(PIN_VERDE, OUTPUT);
Wire.begin(OLED_SDA, OLED_SCL);
if (OLED_RST >= 0) {
pinMode(OLED_RST, OUTPUT);
digitalWrite(OLED_RST, LOW); delay(20); digitalWrite(OLED_RST, HIGH);
}
if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) Serial.println("No se encuentra la OLED");
display.setTextColor(SSD1306_WHITE);
pintar("APAGADO");
BLEDevice::init("Heltec-Lince");
BLEServer *servidor = BLEDevice::createServer();
servidor->setCallbacks(new AlConectar());
BLEService *svc = servidor->createService(SERVICE_UUID);
tx = svc->createCharacteristic(UUID_TX, BLECharacteristic::PROPERTY_NOTIFY);
tx->addDescriptor(new BLE2902());
BLECharacteristic *rx = svc->createCharacteristic(
UUID_RX, BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_WRITE_NR);
rx->setCallbacks(new AlRecibir());
svc->start();
BLEAdvertising *pub = BLEDevice::getAdvertising();
pub->addServiceUUID(SERVICE_UUID);
pub->setScanResponse(true);
BLEDevice::startAdvertising();
}
void loop() {
static bool antes = false;
if (conectado != antes) { // cambio de estado: refrescar el aviso de la pantalla
antes = conectado;
entrada = "";
pintar(actual.c_str());
}
if (hayLinea) { // llegó una orden: ejecutarla fuera del callback
String t = pendiente;
hayLinea = false;
t.trim();
t.toUpperCase();
orden(t);
}
if (secuencia && millis() - cambioEn >= DURACION[paso]) {
paso = (paso + 1) % 3;
cambioEn = millis();
luz(paso == 0 ? "VERDE" : paso == 1 ? "AMARILLO" : "ROJO");
}
delay(10);
}
Código 2: la página web (HTML)
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Chisme 27 · El semáforo del Lince (Heltec)</title>
<style>
:root{
--bg:#eef2ef; --ink:#10201c; --soft:#4d625b; --card:#ffffff; --line:#cfdad5;
--pine:#1f5b4a; --slow:#2563a8; --ok:#1f7a4d; --norte:#c8321f; --oro:#b8892b;
box-sizing:border-box;
padding-top:env(safe-area-inset-top,0px); padding-bottom:env(safe-area-inset-bottom,0px);
}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){
--bg:#0f1a17; --ink:#e8f1ed; --soft:#9db3aa; --card:#172622; --line:#2a3d37;
--pine:#5fc4a2; --slow:#7db3ee; --ok:#6fd89f; --norte:#ff6a55; --oro:#e0b45a;}}
:root[data-theme="dark"]{--bg:#0f1a17; --ink:#e8f1ed; --soft:#9db3aa; --card:#172622; --line:#2a3d37;
--pine:#5fc4a2; --slow:#7db3ee; --ok:#6fd89f; --norte:#ff6a55; --oro:#e0b45a;}
*{box-sizing:border-box}
html{scroll-padding-top:env(safe-area-inset-top,0px)}
body{margin:0;background:var(--bg);color:var(--ink);font:18px/1.45 "Trebuchet MS","Segoe UI",system-ui,sans-serif}
.tira{height:.55rem;background:linear-gradient(90deg,var(--oro),var(--norte),var(--pine))}
main{max-width:30rem;margin:0 auto;padding:1rem 1rem 2rem;text-align:center}
h1{font-size:1.6rem;margin:.2rem 0 .1rem}
.sub{color:var(--soft);margin:0 0 .9rem;font-size:.95rem}
button{font:inherit;font-weight:700;border:2px solid var(--pine);background:var(--pine);color:var(--bg);
border-radius:.9rem;padding:.7rem 1.1rem;min-height:3.1rem;cursor:pointer}
button.alt{background:transparent;color:var(--pine)}
button:disabled{opacity:.5;cursor:not-allowed}
button:focus-visible,input:focus-visible{outline:3px solid var(--slow);outline-offset:2px}
.fila{display:flex;gap:.5rem;justify-content:center;flex-wrap:wrap}
#estado{margin:.7rem 0 .1rem;color:var(--soft);min-height:1.5em}
#ayuda{margin:0 0 .4rem;color:var(--soft);font-size:.88rem;min-height:1.1em}
.semaforo{width:8.5rem;margin:1rem auto;background:#1b1f1e;border:3px solid #3a4441;border-radius:1.4rem;padding:.9rem;display:grid;gap:.8rem}
.luz{aspect-ratio:1;border-radius:50%;background:#2a3230;border:3px solid #3a4441;transition:background .15s,box-shadow .15s}
.luz.r.on{background:#ff3b30;box-shadow:0 0 1.6rem #ff3b30}
.luz.y.on{background:#ffcc00;box-shadow:0 0 1.6rem #ffcc00}
.luz.g.on{background:#34d058;box-shadow:0 0 1.6rem #34d058}
.fila.ordenes{flex-direction:column;align-items:center;margin-top:.6rem}
.ord{width:100%;max-width:21rem;font-size:1.15rem;min-height:3.3rem}
.ord.r{background:#c8321f;border-color:#c8321f;color:#fff}
.ord.y{background:#e6b800;border-color:#e6b800;color:#222}
.ord.g{background:#1f9d4d;border-color:#1f9d4d;color:#fff}
#info{color:var(--ok);font-weight:700;min-height:1.4em;margin-top:.6rem}
.guia{text-align:left;background:var(--card);border:1px solid var(--line);border-radius:.8rem;padding:.2rem .9rem;margin-top:1.2rem}
.guia summary{font-weight:700;cursor:pointer;padding:.6rem 0}
.guia ol{padding-left:1.3rem;margin:.2rem 0 .6rem}
.guia li{margin:.4rem 0}
</style>
</head>
<body>
<div class="tira"></div>
<main>
<h1>El semáforo del Lince</h1>
<p class="sub">Enciende las luces del semáforo de la Heltec desde el móvil.</p>
<div class="fila"><button id="conectar">Conectar Heltec</button></div>
<div id="estado" role="status">Sin conectar.</div>
<div id="ayuda"></div>
<div class="semaforo" aria-label="Semáforo en pantalla: copia el de la placa">
<div class="luz r" id="lr"></div><div class="luz y" id="ly"></div><div class="luz g" id="lg"></div>
</div>
<div id="info" aria-live="polite">Apagado</div>
<div class="fila ordenes">
<button class="ord r" data-o="ROJO" disabled>Rojo</button>
<button class="ord y" data-o="AMARILLO" disabled>Amarillo</button>
<button class="ord g" data-o="VERDE" disabled>Verde</button>
<button class="ord alt" data-o="SECUENCIA" disabled>Semáforo automático</button>
<button class="ord alt" data-o="APAGAR" disabled>Apagar</button>
</div>
<details class="guia">
<summary>Guía de uso</summary>
<ol>
<li>Conecta el módulo semáforo: <b>R</b> al pin 13, <b>Y</b> al 4, <b>G</b> al 23 y <b>GND</b> a GND.</li>
<li>Carga <b>chisme-27-heltec-semaforo.ino</b> en la Heltec desde el Arduino IDE.</li>
<li>Abre esta página con <b>Chrome</b> y pulsa <b>Conectar Heltec</b>. Elige <b>Heltec-Lince</b>.</li>
<li>Pulsa un color: se enciende en la placa y el semáforo de la pantalla lo copia. «Semáforo automático» hace el ciclo verde, amarillo y rojo solo.</li>
</ol>
</details>
</main>
<script>
const UART_SERVICE = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
const UART_TX = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"; // la Heltec envía por aquí
const $ = id => document.getElementById(id);
const espera = ms => new Promise(r => setTimeout(r, ms));
const vibra = p => { if (navigator.vibrate) navigator.vibrate(p); };
let rx = null, dispositivo = null, conectando = false, reconectando = false, ocupado = false, buffer = "", pasoCx = "", diag = "";
let auto = false;
/* Envío: una línea terminada en \n, en trozos de 20 bytes */
const propsDe = c => ["read", "write", "writeWithoutResponse", "notify", "indicate"].filter(k => c.properties[k]).join(", ");
async function enviarTrozo(t){
const sr = rx.properties.writeWithoutResponse && typeof rx.writeValueWithoutResponse === "function";
const orden = rx.properties.write
? ["writeValueWithResponse", "writeValue"].concat(sr ? ["writeValueWithoutResponse"] : [])
: ["writeValueWithoutResponse"];
let ult = null;
for (const m of orden){
if (typeof rx[m] !== "function") continue;
try { await rx[m](t); return; } catch (e){ ult = e; ult.metodo = m; await espera(100); }
}
throw ult;
}
async function escribir(txt){
if (!rx) throw new Error("No se encuentra el canal de escritura. Características: " + diag);
const bytes = new TextEncoder().encode(txt + "\n");
for (let pos = 0; pos < bytes.length; pos += 20){
try { await enviarTrozo(bytes.slice(pos, pos + 20)); await espera(30); }
catch (e){
const err = new Error((e && e.message ? e.message : "fallo desconocido") + " [byte " + pos + " de " + bytes.length + ", método " + (e && e.metodo) + "]");
err.name = e && e.name ? e.name : "Error"; throw err;
}
}
}
async function mandar(texto){
if (ocupado) return;
ocupado = true;
try { await escribir(texto); vibra(30); }
catch (e){ estado("No se pudo enviar (" + (e.name || "error") + ").", (e.message || "") + " Comprueba que la Heltec sigue conectada."); }
ocupado = false;
}
document.querySelectorAll(".ord").forEach(b => b.onclick = () => {
auto = b.dataset.o === "SECUENCIA";
mandar(b.dataset.o);
});
/* La Heltec nos cuenta qué luz ha encendido: «OK:ROJO», «OK:VERDE»... */
function pintaLuz(n){
$("lr").classList.toggle("on", n === "ROJO");
$("ly").classList.toggle("on", n === "AMARILLO");
$("lg").classList.toggle("on", n === "VERDE");
$("info").textContent = (n === "APAGADO" ? "Apagado" : "Luz " + n.toLowerCase()) + (auto && n !== "APAGADO" ? " (automático)" : "");
}
function linea(t){
if (!t.startsWith("OK:")) return;
const n = t.slice(3);
if (n === "SECUENCIA") auto = true; else pintaLuz(n);
}
function recibir(ev){
buffer += new TextDecoder().decode(ev.target.value);
let i;
while ((i = buffer.indexOf("\n")) >= 0){ linea(buffer.slice(0, i).trim()); buffer = buffer.slice(i + 1); }
}
/* Conexión Bluetooth (mismo esquema que las páginas 21 a 25) */
function estado(t, ayuda){ if (!/^Error/.test(t)) pasoCx = t; $("estado").textContent = t; $("ayuda").textContent = ayuda || ""; }
function botones(on){ document.querySelectorAll(".ord").forEach(b => b.disabled = !on); }
function explicar(e){
const nombre = e && e.name ? e.name : "Error", m = e && e.message ? e.message : String(e);
let a = "";
if (nombre === "NotFoundError" && /cancel/i.test(m)) a = "No has elegido ningún dispositivo. Pulsa Conectar y elige Heltec-Lince.";
else if (nombre === "NotFoundError") a = "La placa no tiene el servicio UART. Comprueba que has cargado el programa del Chisme 27.";
else if (nombre === "NotSupportedError") a = "No se pudo activar el envío de datos. Olvida la Heltec en Ajustes → Bluetooth y reinicia el móvil y la placa.";
else if (nombre === "NetworkError") a = "No se pudo abrir la conexión. Comprueba que no haya otra app conectada y reinicia la placa (botón RST).";
else if (nombre === "SecurityError") a = "La página debe abrirse con https://.";
else if (nombre === "NotAllowedError") a = "Permiso denegado. Permite el Bluetooth y los dispositivos cercanos para el navegador.";
estado("Error (" + nombre + "): " + m, "Falló en el paso: «" + pasoCx + "». " + a + (diag ? " Características: " + diag : ""));
}
async function conectarGatt(dev){
let ultimoError;
for (let k = 1; k <= 4; k++){
try {
estado("Conectando… (intento " + k + " de 4)");
const srv = dev.gatt.connected ? dev.gatt : await dev.gatt.connect();
await espera(600);
estado("Buscando el servicio UART…");
return await srv.getPrimaryService(UART_SERVICE);
} catch (e){
ultimoError = e;
if (e.name === "NotFoundError" || e.name === "SecurityError") break;
try { if (dev.gatt.connected) dev.gatt.disconnect(); } catch (_){}
await espera(800);
}
}
throw ultimoError;
}
async function enlazar(dev){
const svc = await conectarGatt(dev);
buffer = ""; diag = "";
estado("Leyendo las características del servicio…");
const chars = await svc.getCharacteristics();
diag = chars.map(c => c.uuid.slice(0, 8) + " [" + propsDe(c) + "]").join(" · ");
const cand = chars.filter(c => c.properties.notify || c.properties.indicate)
.sort((a, b) => (b.uuid === UART_TX) - (a.uuid === UART_TX));
if (!cand.length) throw new Error("El servicio UART no tiene ninguna característica que envíe datos.");
let ch = null, ultimoErr;
for (const c of cand){
c.addEventListener("characteristicvaluechanged", recibir);
for (let i = 1; i <= 2 && !ch; i++){
try { estado("Activando notificaciones en " + c.uuid.slice(0, 8) + "… (intento " + i + ")"); await c.startNotifications(); ch = c; }
catch (e){ ultimoErr = e; await espera(1000); }
}
if (ch) break;
c.removeEventListener("characteristicvaluechanged", recibir);
}
if (!ch) throw ultimoErr;
rx = chars.find(c => c.uuid.startsWith("6e400002") && (c.properties.write || c.properties.writeWithoutResponse)) || null;
botones(true);
estado("Conectado a " + (dev.name || "Heltec") + ". ¡Pulsa una luz!");
}
async function reconectar(){
rx = null; botones(false);
if (reconectando) return;
reconectando = true;
for (let i = 1; i <= 20; i++){
estado("Conexión perdida. Reintentando… (" + i + " de 20)", "Mantén la Heltec cerca y encendida.");
await espera(3000);
try { await enlazar(dispositivo); reconectando = false; return; } catch (_) {}
}
reconectando = false;
estado("No se pudo reconectar.", "Pulsa Conectar para volver a empezar.");
}
$("conectar").onclick = async () => {
if (conectando || reconectando) return;
if (!navigator.bluetooth){ estado("Este navegador no permite Bluetooth.", "Usa Chrome o Edge (en ordenador o en Android)."); return; }
conectando = true; $("conectar").disabled = true;
try {
estado("Buscando la Heltec…");
const dev = await navigator.bluetooth.requestDevice({ acceptAllDevices: true, optionalServices: [UART_SERVICE] });
dispositivo = dev;
dev.addEventListener("gattserverdisconnected", reconectar);
await enlazar(dev);
} catch (e){ explicar(e); }
finally { conectando = false; $("conectar").disabled = false; }
};
</script>
</body>
</html>
Código 3: prueba de las luces (Arduino IDE)
/*
Chisme 27 · Prueba de las luces del semáforo
Enciende rojo, amarillo y verde, de uno en uno, durante 1 segundo cada uno.
Placa en el Arduino IDE: "ESP32 Dev Module".
*/
#define PIN_ROJO 13
#define PIN_AMARILLO 4
#define PIN_VERDE 23
void setup() {
pinMode(PIN_ROJO, OUTPUT);
pinMode(PIN_AMARILLO, OUTPUT);
pinMode(PIN_VERDE, OUTPUT);
}
void loop() {
digitalWrite(PIN_ROJO, HIGH); delay(1000); digitalWrite(PIN_ROJO, LOW);
digitalWrite(PIN_AMARILLO, HIGH); delay(1000); digitalWrite(PIN_AMARILLO, LOW);
digitalWrite(PIN_VERDE, HIGH); delay(1000); digitalWrite(PIN_VERDE, LOW);
}
Etiqueta:AIDARAC, Arduino IDE, Chismes del Lince, esp32, Heltec LoRa 32, led, montesteam, Semáforo Bluetooth, Web Bluetooth

