Chismes del Lince 37: El telégrafo de Justicia Ciega
Anocheció pronto en Sierra Bermeja. Los pinsapos eran sombras y las jaras olían a resina fría. El Lince de la Noche, la Gata Montesa y Justicia Ciega llevaban una hora agazapados detrás de una roca, mirando hacia un barranco.
—Hay algo ahí abajo —susurró Justicia Ciega.
No lo veía, claro. Lo oía. Su casco captaba pasos, ramas y un jadeo bajito que ni el Lince ni la Gata habían notado.
—Una cabra montés con su cría —dijo—. Si alguien habla alto o se acerca, se asustan y se despeñan. Hay que avisar al resto del equipo de que se queden quietos. Pero sin decir una palabra.
—Fácil —dijo el Lince, y se frotó las patas—. Montaré una central de comunicaciones: cuarenta botones, reconocimiento de voz, una IA que lo traduzca todo…
—Lince —dijo la Gata Montesa—. Justicia no ve los botones.
—Pues botones en relieve.
—Cuarenta botones en relieve, en la oscuridad, sin hacer ruido.
—Bueno… —El Lince se rascó una oreja—. Quizá cincuenta.
—¿Y si bastara con una sola tecla? —preguntó la Gata.
—¿Con una? ¿Cómo se escribe el alfabeto con una tecla?
—Con dos señales: una corta y una larga. Hace casi dos siglos lo hacían con un cable y un botón.
Justicia Ciega giró la cabeza hacia ellos y sonrió.
—Morse —dijo—. Se me da bien. Lo oigo en una sola pulsación.
El Lince abrió su mochila y sacó una placa Heltec. La miró por todos lados. En un lateral, casi escondido, había un botoncito.
—Un telégrafo —murmuró—. El chisme de hoy.
—Un telégrafo —confirmó la Gata—. Y esta vez, Lince, no lo compliques. Que sea un botón, una pantalla y un móvil que lo lea en voz alta.
Introducción
En este chisme construimos un telégrafo: Morse con ESP32, usando el botón que ya trae la propia placa. Pulsas corto o largo, la placa traduce las pulsaciones a letras y las escribe en su pantalla.
Además las manda por Bluetooth al móvil, que las dice en voz alta. Así, quien escribe no necesita ver nada, y quien escucha no necesita mirar la placa.
No hay cables ni piezas nuevas: solo la Heltec (o TTGO) con su pantalla, su botón y un móvil con Chrome.
Y hay un detalle científico que lo convierte en algo más que un juguete: la página mide cuánto dura cada pulsación y, con esos datos, calcula cuándo una pulsación cuenta como corta y cuándo como larga. Cada persona pulsa a su ritmo, y el telégrafo se ajusta a ella.
Por qué este chisme
Una pantalla táctil no sirve a quien no ve. Un teclado de cuarenta teclas tampoco, si no sabes dónde está cada una. Pero un solo botón se encuentra con el dedo, en la oscuridad y sin hacer ruido.
Con un solo botón y dos tipos de pulsación, el Morse con ESP32 permite escribir cualquier mensaje. Ese es el truco del código Morse, y por eso sigue siendo útil: funciona con casi cualquier cosa que se pueda encender y apagar.
Para el Lince y sus compañeros es además una cuestión de equipo. Justicia Ciega puede mandar un aviso sin hablar, y el resto lo oye por el móvil sin tener que mirar una pantalla.
Para el alumnado, el reto es otro: una pulsación corta y una larga no son dos valores exactos, son dos grupos de tiempos que hay que separar con datos. Ese paso, de la señal al dato y del dato a la decisión, es el que repetiremos en muchos chismes.
Cómo funciona el Morse con ESP32
—¿Y cómo sabe la placa si una pulsación es un punto o una raya? —preguntó el Lince.
—Mide cuánto dura —dijo la Gata—. Corta es punto, larga es raya. Pero ahora hay que decidir qué es “corta”.
El código Morse se basa en una unidad de tiempo: lo que dura un punto. La raya dura tres unidades. Entre los puntos y rayas de una misma letra hay una unidad de silencio, entre letras tres, y entre palabras siete. Son las proporciones del Morse internacional, definido en la Recomendación UIT-R M.1677-1.
Nuestra placa usa una unidad de 250 ms. Una pulsación de menos de dos unidades (500 ms) cuenta como punto; de dos o más, como raya. Una pausa de tres unidades cierra la letra, y una de siete cierra la palabra.
La placa no “sabe” Morse: tiene una tabla con las 36 combinaciones (26 letras y 10 números) y busca la que coincide con lo que has pulsado. Si no hay ninguna, escribe un signo de interrogación.
El botón de la placa
El botón de tu placa está en el pin GPIO 36. Es un pin de solo entrada, y los pines 34 a 39 del ESP32 no tienen resistencias internas de pull-up ni de pull-down. Por eso el programa no activa ninguna por software: el botón funciona porque la propia placa ya trae su resistencia externa.
Hay un detalle más: según la placa, el botón lee HIGH o LOW cuando está suelto. Así que el programa lo mira al arrancar y apunta ese valor como “reposo”; pulsado es cualquier otro. Por eso no hay que tocar el botón durante los primeros segundos.
Los rebotes
Un botón mecánico no cierra el contacto de golpe: rebota unas décimas de milisegundo. El programa ignora cualquier cambio que llegue menos de 30 ms después del anterior, y descarta las pulsaciones de menos de 40 ms por ser toques falsos.
Qué necesitamos
- Una placa Heltec LoRa 32 (o TTGO LoRa32) con pantalla OLED y su cable USB.
- Un móvil con Chrome (Web Bluetooth).
- Arduino IDE con las librerías Adafruit SSD1306 y Adafruit GFX.
- Dos archivos: chisme-37-telegrafo.ino (la placa) y chisme-37-telegrafo.html (la página).
Nada más: no hay que conectar ningún cable ni sensor, ni soldar nada. Esta vez el “hardware” es el botón que ya trae la placa, y es lo más sencillo para empezar con Morse con ESP32.


Construcción
Esta vez no hay nada que montar. Solo hay que saber en qué pin está el botón de tu placa.
- Localiza el botón de usuario. En la TTGO LoRa32 V1.3 de las pruebas está en el GPIO 36. El botón RST no sirve: reinicia la placa y no se puede leer.
- Si tu botón está en otro pin, cámbialo en el programa (
PIN_BOTON). Más abajo hay un programa de prueba para averiguarlo. - Sube el programa a la placa y abre el Monitor Serie a 115200 baudios.
- Abre la página web en Chrome. Lo demás está en «Cómo probarlo».
¿En qué pin está tu botón?
Cada placa es distinta, y en algunas el botón PRG no responde. Este programa vigila varios pines a la vez. Cuando pulsas un botón, escribe en la pantalla y en el Monitor Serie cuál ha cambiado.
Súbelo, espera dos segundos sin tocar nada y pulsa los botones (menos RST). Si sale «GPIO 36», ese número es el que pondrás en PIN_BOTON.
/*
PRUEBA DE BOTONES · Heltec / TTGO LoRa32 V1.3
Pulsa los botones de la placa. El programa mira varios pines a la vez y te
dice CUAL ha cambiado, en la pantalla y en el Monitor Serie (115200 baudios).
Asi sabremos si el boton de tu placa existe y en que pin esta.
NO toques el boton RST (el de reset): ese reinicia la placa y no se puede leer.
Librerias: Adafruit SSD1306 y Adafruit GFX.
Placa en el Arduino IDE: "ESP32 Dev Module".
*/
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
Adafruit_SSD1306 display(128, 64, &Wire, 16); // la OLED: SDA 21, SCL 22, RST 16
// Los pines que vamos a vigilar (no incluyen los de la OLED ni los del LoRa)
const int PINES[] = {0, 2, 4, 12, 13, 15, 17, 23, 25, 32, 33, 34, 35, 36, 39};
const int N = sizeof(PINES) / sizeof(PINES[0]);
int reposo[N]; // como estaba cada pin sin tocar nada
bool ruidoso[N]; // true = ese pin va "flotando" y cambia solo: lo ignoramos
void pantalla(String linea1, String linea2, String linea3) {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(1);
display.setCursor(0, 0); display.print("PRUEBA DE BOTONES");
display.setTextSize(2);
display.setCursor(0, 16); display.print(linea1);
display.setCursor(0, 36); display.print(linea2);
display.setTextSize(1);
display.setCursor(0, 56); display.print(linea3);
display.display();
}
void setup() {
Serial.begin(115200);
Wire.begin(21, 22);
pinMode(16, OUTPUT);
digitalWrite(16, LOW); delay(20); digitalWrite(16, HIGH);
display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
for (int i = 0; i < N; i++) // 34 a 39 no tienen resistencia interna
pinMode(PINES[i], PINES[i] >= 34 ? INPUT : INPUT_PULLUP);
pantalla("Sin tocar", "nada...", "calibrando 2 s");
delay(300);
// Durante 2 segundos contamos cuantas veces cambia cada pin SIN tocar nada.
// Un pin que cambia solo esta "flotando" (sin nada conectado): lo ignoramos.
int cambios[N], anterior[N];
for (int i = 0; i < N; i++) { cambios[i] = 0; anterior[i] = digitalRead(PINES[i]); }
for (int t = 0; t < 400; t++) {
for (int i = 0; i < N; i++) {
int v = digitalRead(PINES[i]);
if (v != anterior[i]) { cambios[i]++; anterior[i] = v; }
}
delay(5);
}
for (int i = 0; i < N; i++) {
ruidoso[i] = cambios[i] > 3;
reposo[i] = digitalRead(PINES[i]);
if (ruidoso[i]) { Serial.print("Ignoro el GPIO "); Serial.println(PINES[i]); }
}
Serial.println("Listo. Pulsa los botones.");
pantalla("Pulsa un", "boton", "(menos RST)");
}
void loop() {
for (int i = 0; i < N; i++) {
if (ruidoso[i]) continue;
int v = digitalRead(PINES[i]);
if (v == reposo[i]) continue;
delay(20); // esperamos 20 ms: que no sea un falso contacto
if (digitalRead(PINES[i]) != v) continue;
reposo[i] = v; // apuntamos el cambio
String estado = (v == LOW) ? "PULSADO" : "suelto";
Serial.print("GPIO "); Serial.print(PINES[i]); Serial.print(": "); Serial.println(estado);
pantalla("GPIO " + String(PINES[i]), estado, "cambio detectado");
}
}
El código de la placa
Copia todo el código en un sketch nuevo del Arduino IDE. Tiene cuatro partes, pero solo las PARTES 1 y 2 son el chisme: la tabla del Morse y la medición del tiempo.
/*
Chisme 37 · El telegrafo de Justicia Ciega
Pulsas el boton de la placa: corto = punto, largo = raya (codigo Morse).
La placa traduce las pulsaciones a letras, las escribe en su pantalla y las
manda por Bluetooth al movil, que las dice en voz alta.
Este programa tiene 4 partes. Solo las PARTES 1 y 2 son el chisme:
PARTE 1 · EL MORSE <- LEE ESTA. La tabla y como se traduce una letra.
PARTE 2 · EL BOTON <- LEE ESTA. Mide cuanto dura cada pulsacion.
PARTE 3 · LA PANTALLA <- NO ES NECESARIA. Solo escribe el mensaje en la OLED.
PARTE 4 · EL BLUETOOTH <- Como en el chisme 35. No hace falta tocarla.
(y al final, setup() y loop())
NO HAY QUE CONECTAR NADA: se usa el boton que ya trae la placa.
En la TTGO LoRa32 V1.3 de las pruebas, ese boton esta en el GPIO 36.
Si en tu placa esta en otro pin (por ejemplo el GPIO 0 en muchas Heltec),
cambia el numero de PIN_BOTON (PARTE 2).
IMPORTANTE: no toques el boton mientras la placa arranca (los primeros 2 segundos).
PUERTO SERIE: Herramientas -> Monitor Serie, a 115200 baudios.
Librerias: Adafruit SSD1306 y Adafruit GFX.
Placa en el Arduino IDE: "ESP32 Dev Module".
Si al compilar dice "Sketch too big":
Herramientas -> Partition Scheme -> "Huge APP (3MB No OTA/1MB SPIFFS)".
*/
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
/* ============================================================
PARTE 1 · EL MORSE (lo importante)
Cada letra es una combinacion de puntos (.) y rayas (-).
CODIGO[0] es el de la A, CODIGO[1] el de la B... y LETRAS dice a que letra
corresponde cada uno. OJO: los dos deben estar en el mismo orden.
============================================================ */
const char* CODIGO[] = {
".-", "-...", "-.-.", "-..", ".", "..-.", "--.", "....", "..", ".---", // A B C D E F G H I J
"-.-", ".-..", "--", "-.", "---", ".--.", "--.-", ".-.", "...", "-", // K L M N O P Q R S T
"..-", "...-", ".--", "-..-", "-.--", "--..", // U V W X Y Z
"-----", ".----", "..---", "...--", "....-", ".....", "-....", "--...", "---..", "----." // 0 a 9
};
const char LETRAS[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
char traducir(String simbolos) { // de ".-" a 'A'. Si no existe, devuelve '?'
for (int i = 0; i < 36; i++) {
if (simbolos == CODIGO[i]) return LETRAS[i];
}
return '?';
}
/* ============================================================
PARTE 2 · EL BOTON (lo importante)
La placa mide CUANTO TIEMPO esta pulsado el boton:
menos de 2 unidades -> punto (.)
2 unidades o mas -> raya (-)
y mide CUANTO TIEMPO pasa sin pulsar:
3 unidades sin pulsar -> se acaba la letra
7 unidades sin pulsar -> se acaba la palabra
Una "unidad" es lo que dura un punto. Se puede cambiar desde la pagina (Calibrar).
============================================================ */
#define PIN_BOTON 36 // el boton de la placa (GPIO 36)
int UNIDAD = 250; // milisegundos que dura un punto
int reposo; // como lee el pin cuando NO esta pulsado (HIGH o LOW)
bool pulsado = false; // ¿esta pulsado ahora mismo?
unsigned long inicioPulso = 0; // cuando empezo la pulsacion
unsigned long finPulso = 0; // cuando se solto el boton por ultima vez
unsigned long ultimoCambio = 0; // para ignorar los rebotes
String simbolos = ""; // los puntos y rayas de la letra que se esta escribiendo
String mensaje = ""; // todo lo escrito
bool palabraPendiente = false; // ¿falta decidir si acaba la palabra?
void leerBoton() { // se llama sin parar desde loop()
bool ahora = (digitalRead(PIN_BOTON) != reposo); // ¿pulsado?
if (ahora == pulsado) return; // no ha cambiado nada
if (millis() - ultimoCambio < 30) return; // ignoramos los rebotes (30 ms)
ultimoCambio = millis();
pulsado = ahora;
if (pulsado) {
inicioPulso = millis(); // empieza la pulsacion
} else {
unsigned long duracion = millis() - inicioPulso; // ¡aqui se mide el tiempo!
if (duracion < 40) return; // demasiado corta: es un toque falso, no cuenta
char simbolo = (duracion < 2 * UNIDAD) ? '.' : '-';
simbolos += simbolo;
finPulso = millis();
Serial.println(String("pulso ") + simbolo + " " + duracion + " ms");
avisar(String("P") + simbolo + duracion); // la pagina anota la duracion
pintarPantalla();
}
}
void comprobarPausas() { // ¿ha acabado la letra? ¿y la palabra?
if (pulsado) return;
unsigned long silencio = millis() - finPulso;
if (simbolos.length() > 0 && silencio > 3UL * UNIDAD) { // fin de letra
char letra = (simbolos.length() <= 5) ? traducir(simbolos) : '?';
Serial.println(String("letra ") + simbolos + " = " + letra);
mensaje += letra;
if (mensaje.length() > 40) mensaje = mensaje.substring(mensaje.length() - 40);
simbolos = "";
palabraPendiente = true;
avisar(String("L") + letra);
pintarPantalla();
}
if (palabraPendiente && simbolos.length() == 0 && silencio > 7UL * UNIDAD) { // fin de palabra
mensaje += ' ';
palabraPendiente = false;
avisar("W");
pintarPantalla();
}
}
/* ============================================================
PARTE 3 · LA PANTALLA (NO ES NECESARIA: el telegrafo funciona sin ella)
Escribe el mensaje en la OLED de la placa.
============================================================ */
#define OLED_SDA 21
#define OLED_SCL 22
#define OLED_RST 16
#define OLED_ADDR 0x3C
Adafruit_SSD1306 display(128, 64, &Wire, OLED_RST);
volatile bool conectado = false; // ¿hay un movil conectado?
void pintarPantalla() {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(1);
display.setCursor(0, 0);
display.print(conectado ? "TELEGRAFO - conectado" : "TELEGRAFO - esperando");
display.setTextSize(2);
display.setCursor(0, 12);
display.print(simbolos.length() > 0 ? simbolos : String("_")); // lo que llevas de la letra
display.setTextSize(1);
String cola = mensaje.length() > 42 ? mensaje.substring(mensaje.length() - 42) : mensaje;
String linea1 = cola.length() > 21 ? cola.substring(0, cola.length() - 21) : cola;
String linea2 = cola.length() > 21 ? cola.substring(cola.length() - 21) : String("");
display.setCursor(0, 34); display.print(linea1);
display.setCursor(0, 44); display.print(linea2);
display.setCursor(0, 56); display.print(String("Unidad: ") + UNIDAD + " ms");
display.display();
}
void arrancarPantalla() {
Wire.begin(OLED_SDA, OLED_SCL);
pinMode(OLED_RST, OUTPUT);
digitalWrite(OLED_RST, LOW); delay(20); digitalWrite(OLED_RST, HIGH);
display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR);
}
/* ============================================================
PARTE 4 · EL BLUETOOTH (como en el chisme 35)
La placa se anuncia como "Heltec-Telegrafo". Tiene dos "buzones":
TX: por aqui la placa AVISA a la pagina:
"P.312" = pulsacion corta que duro 312 ms "P-640" = pulsacion larga
"LA" = ha salido la letra A "W" = fin de palabra "U250" = la unidad actual
RX: por aqui LLEGAN las ordenes de la pagina:
"U200" = cambia la unidad a 200 ms "B" = borra el mensaje "?" = dime la unidad
============================================================ */
#define SERVICE_UUID "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
#define UUID_RX "6e400002-b5a3-f393-e0a9-e50e24dcca9e"
#define UUID_TX "6e400003-b5a3-f393-e0a9-e50e24dcca9e"
BLECharacteristic *tx = nullptr;
String ordenPendiente = ""; // orden llegada del movil (la atiende loop())
volatile bool hayOrden = false;
class AlConectar : public BLEServerCallbacks {
void onConnect(BLEServer *s) { conectado = true; }
void onDisconnect(BLEServer *s) { conectado = false; BLEDevice::startAdvertising(); }
};
class AlEscribir : public BLECharacteristicCallbacks { // llega algo desde la pagina
void onWrite(BLECharacteristic *c) {
ordenPendiente = String(c->getValue().c_str()); // la guardamos; loop() la atendera
hayOrden = true;
}
};
void avisar(String texto) { // manda un mensaje corto a la pagina
if (!conectado || !tx) return;
tx->setValue((uint8_t *)texto.c_str(), texto.length());
tx->notify();
}
void atenderOrden(String orden) {
if (orden.length() == 0) return;
if (orden[0] == 'U') { // U200 = cambia la unidad
UNIDAD = constrain(orden.substring(1).toInt(), 80, 800);
avisar(String("U") + UNIDAD);
}
if (orden[0] == 'B') { // B = borra el mensaje
mensaje = "";
simbolos = "";
palabraPendiente = false;
}
if (orden[0] == '?') avisar(String("U") + UNIDAD);
pintarPantalla();
}
void arrancarBluetooth() {
BLEDevice::init("Heltec-Telegrafo");
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 AlEscribir());
svc->start();
BLEAdvertising *pub = BLEDevice::getAdvertising();
pub->addServiceUUID(SERVICE_UUID);
pub->setScanResponse(true);
BLEDevice::startAdvertising();
}
/* ============================================================
setup() se ejecuta UNA vez al encender. loop() se repite sin parar.
============================================================ */
void setup() {
Serial.begin(115200);
arrancarPantalla();
pinMode(PIN_BOTON, PIN_BOTON >= 34 ? INPUT : INPUT_PULLUP); // 34 a 39 no tienen resistencia interna
delay(300);
reposo = digitalRead(PIN_BOTON); // apunta como esta el pin SIN pulsar
arrancarBluetooth();
pintarPantalla();
Serial.println("Listo. Pulsa el boton: corto = punto, largo = raya.");
}
bool conectadoAntes = false;
void loop() {
leerBoton(); // ¿se ha pulsado o soltado el boton?
comprobarPausas(); // ¿ha acabado una letra o una palabra?
if (hayOrden) { // ¿ha llegado una orden del movil?
hayOrden = false;
atenderOrden(ordenPendiente);
}
if (conectado != conectadoAntes) { // ¿cambio la conexion?
conectadoAntes = conectado;
pintarPantalla();
}
delay(2);
}
Lee y predice
- Con
UNIDAD = 250, ¿una pulsación de 400 ms es un punto o una raya? ¿Y una de 520 ms? - Para escribir la K (raya, punto, raya), ¿qué pausa máxima puedes dejar entre pulsaciones sin que la placa cierre la letra?
- Si cambias
2 * UNIDADpor4 * UNIDADenleerBoton(), ¿qué pasará con las rayas?
Soluciones: 1) 400 ms es un punto (menos de 500) y 520 ms es una raya. 2) Menos de tres unidades, es decir, 750 ms: con una pausa mayor, la placa cierra la letra. 3) El umbral sube a 1000 ms: casi todo serán puntos y habría que mantener pulsado más de un segundo para hacer una raya.
La página web
Guarda este código como chisme-37-telegrafo.html y ábrelo en Chrome.
La PARTE 1 es lo importante: recibe lo que manda la placa, lo escribe en grande y lo dice en voz alta. La PARTE 2 es la ciencia: dibuja el histograma de las duraciones y calibra el umbral. La PARTE 3 es el Bluetooth, casi igual que en el chisme 35.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chisme 37 · El telégrafo de Justicia Ciega</title>
<style>
/* Los colores, en un solo sitio: cámbialos y cambia toda la página */
:root { --fondo:#eef2ef; --texto:#10201c; --suave:#4d625b; --verde:#1f5b4a;
--corta:#2e9e5b; --larga:#e0802a; --aviso:#ffd23f; }
@media (prefers-color-scheme: dark) {
:root { --fondo:#0f1a17; --texto:#e8f1ed; --suave:#9db3aa; --verde:#5fc4a2; }
}
body { margin:0; padding:1.5rem 1rem; background:var(--fondo); color:var(--texto);
font:18px/1.45 system-ui, sans-serif; text-align:center; }
h1 { font-size:1.5rem; margin:0 0 .3rem; }
h2 { font-size:1.1rem; margin:1.5rem 0 .3rem; }
p { color:var(--suave); margin:.3rem 0; }
button { font:inherit; font-weight:700; padding:.7rem 1.1rem; margin:.3rem;
border:2px solid var(--verde); background:var(--verde); color:var(--fondo);
border-radius:.9rem; cursor:pointer; touch-action:manipulation; }
button:disabled { opacity:.45; cursor:not-allowed; }
/* El mensaje, en grande para quien ve poco */
#mensaje { font-size:2.2rem; font-weight:700; letter-spacing:.05em; min-height:3.5rem;
margin:1rem auto; padding:.6rem; max-width:30rem; word-break:break-word; }
#actual { font-size:2rem; letter-spacing:.3em; min-height:2.6rem; color:var(--verde); }
/* El histograma: una barra por cada tramo de 100 ms */
#histograma { display:flex; align-items:flex-end; gap:3px; height:8rem; max-width:30rem;
margin:.8rem auto .2rem; border-bottom:2px solid var(--suave); }
.barra { flex:1; min-height:2px; border-radius:3px 3px 0 0; }
.barra.corta { background:var(--corta); }
.barra.larga { background:var(--larga); }
#eje { display:flex; justify-content:space-between; max-width:30rem; margin:0 auto;
font-size:.8rem; color:var(--suave); }
details { max-width:30rem; margin:1.5rem auto; text-align:left; }
summary { cursor:pointer; font-weight:700; }
#tabla { columns:2; font-family:ui-monospace, monospace; font-size:.95rem; }
</style>
</head>
<body>
<h1>El telégrafo de Justicia Ciega</h1>
<p>Pulsa el botón de la placa: corto = punto, largo = raya.</p>
<p id="estado">Sin conectar.</p>
<button id="botonConectar">Conectar la placa</button>
<!-- aria-live: los lectores de pantalla (TalkBack, VoiceOver) anuncian solos cada cambio -->
<div id="mensaje" aria-live="polite">...</div>
<div id="actual" aria-hidden="true"></div>
<button id="botonVoz" disabled>Voz: letras</button>
<button id="botonSonido" disabled>Pitidos: sí</button>
<button id="botonLeer" disabled>Leer el mensaje</button>
<button id="botonBorrar" disabled>Borrar</button>
<h2>¿Cuánto dura cada pulsación?</h2>
<div id="histograma"></div>
<div id="eje"><span>0 ms</span><span>750 ms</span><span>1500 ms o más</span></div>
<p id="resumen">Todavía no hay pulsaciones.</p>
<button id="botonCalibrar" disabled>Calibrar con mis pulsaciones</button>
<p id="calibracion"></p>
<details>
<summary>Tabla del código Morse</summary>
<div id="tabla"></div>
</details>
<script>
/* ============================================================
PARTE 1 · LO IMPORTANTE (léela)
La placa nos AVISA con mensajes cortos:
"P.312" una pulsación corta que duró 312 ms "P-640" una larga
"LA" ha salido la letra A "W" fin de palabra "U250" la unidad actual
Aquí los leemos, los enseñamos, los decimos en voz alta y anotamos las duraciones.
============================================================ */
let mensaje = ""; // todo lo escrito
let palabra = ""; // la palabra que se está escribiendo
let simbolos = ""; // los puntos y rayas de la letra actual
let unidad = 250; // ms que dura un punto (lo confirma la placa)
const duraciones = []; // las duraciones de TODAS las pulsaciones (ms)
let modoVoz = 0; // 0 = letras, 1 = palabras, 2 = sin voz
let pitidos = true;
function alRecibirAviso(texto) {
const tipo = texto[0];
if (tipo === "P") { // una pulsación: "P.312"
const simbolo = texto[1];
const ms = Number(texto.slice(2));
if ((simbolo !== "." && simbolo !== "-") || isNaN(ms)) return;
simbolos += simbolo;
duraciones.push(ms); // ¡anotamos el dato!
pitar(simbolo);
dibujarHistograma();
} else if (tipo === "L" && texto.length >= 2) { // una letra: "LA"
const letra = texto[1];
simbolos = "";
mensaje += letra;
palabra += letra;
hablarLetra(letra);
} else if (tipo === "W") { // fin de palabra
if (modoVoz === 1 && palabra) hablar(palabra);
palabra = "";
if (!mensaje.endsWith(" ")) mensaje += " ";
} else if (tipo === "U") { // la placa nos dice su unidad
const u = Number(texto.slice(1));
if (!isNaN(u)) { unidad = u; dibujarHistograma(); }
} else return;
mostrar();
}
function mostrar() {
document.getElementById("mensaje").textContent = mensaje.trim() || "...";
document.getElementById("actual").textContent = simbolos.replace(/\./g, "·").replace(/-/g, "—");
}
// ---------- La voz y los pitidos ----------
function hablar(texto) {
if (modoVoz === 2 || !window.speechSynthesis) return;
const voz = new SpeechSynthesisUtterance(texto);
voz.lang = "es-ES";
voz.rate = 1.1;
speechSynthesis.speak(voz);
}
function hablarLetra(letra) {
if (modoVoz !== 0 || !window.speechSynthesis) return;
speechSynthesis.cancel(); // no se acumulan: dice solo la última
hablar(letra === "?" ? "No entendido" : letra);
}
let audio; // se crea al pulsar «Conectar»
function pitar(simbolo) { // punto = pitido corto, raya = largo
if (!pitidos || !audio) return;
const oscilador = audio.createOscillator();
oscilador.frequency.value = 700;
oscilador.connect(audio.destination);
oscilador.start();
oscilador.stop(audio.currentTime + (simbolo === "." ? 0.08 : 0.25));
}
// ---------- Los botones ----------
document.getElementById("botonVoz").onclick = function () {
modoVoz = (modoVoz + 1) % 3;
this.textContent = ["Voz: letras", "Voz: palabras", "Voz: no"][modoVoz];
if (modoVoz === 2 && window.speechSynthesis) speechSynthesis.cancel();
};
document.getElementById("botonSonido").onclick = function () {
pitidos = !pitidos;
this.textContent = pitidos ? "Pitidos: sí" : "Pitidos: no";
};
document.getElementById("botonLeer").onclick = function () {
if (!window.speechSynthesis) return;
const antes = modoVoz;
modoVoz = 0; // leer siempre, aunque la voz esté apagada
speechSynthesis.cancel();
hablar(mensaje.trim() || "Sin mensaje");
modoVoz = antes;
};
document.getElementById("botonBorrar").onclick = function () {
mensaje = ""; palabra = ""; simbolos = "";
mostrar();
enviarOrden("B"); // "B" = la placa también borra
};
/* ============================================================
PARTE 2 · LA CIENCIA: de las duraciones al umbral
Una pulsación "corta" y una "larga" no son dos valores exactos: son dos GRUPOS de tiempos.
Con las duraciones que hemos anotado buscamos el centro de cada grupo,
y ponemos el umbral justo en medio. Eso es "calibrar".
============================================================ */
function media(lista) { return lista.reduce((a, b) => a + b, 0) / lista.length; }
function dibujarHistograma() {
const cubos = new Array(16).fill(0); // tramos de 100 ms: 0-99, 100-199 ... 1500 o más
for (const d of duraciones) cubos[Math.min(15, Math.floor(d / 100))]++;
const maximo = Math.max(1, ...cubos);
const caja = document.getElementById("histograma");
caja.innerHTML = "";
cubos.forEach((n, i) => {
const barra = document.createElement("div");
barra.className = "barra " + (i * 100 + 50 < 2 * unidad ? "corta" : "larga");
barra.style.height = (n / maximo * 100) + "%";
barra.title = i * 100 + " ms: " + n + " pulsaciones";
caja.appendChild(barra);
});
const cortas = duraciones.filter(d => d < 2 * unidad).length;
document.getElementById("resumen").textContent = duraciones.length === 0
? "Todavía no hay pulsaciones."
: duraciones.length + " pulsaciones: " + cortas + " cortas (verde) y " + (duraciones.length - cortas) +
" largas (naranja). Umbral actual: " + 2 * unidad + " ms.";
document.getElementById("botonCalibrar").disabled = duraciones.length < 10 || !canalOrdenes;
}
document.getElementById("botonCalibrar").onclick = function () {
const aviso = document.getElementById("calibracion");
let c1 = Math.min(...duraciones), c2 = Math.max(...duraciones); // empezamos con los extremos
let cortas = [], largas = [];
for (let vuelta = 0; vuelta < 20; vuelta++) { // repartimos y recalculamos los centros
cortas = duraciones.filter(d => Math.abs(d - c1) <= Math.abs(d - c2));
largas = duraciones.filter(d => Math.abs(d - c1) > Math.abs(d - c2));
if (cortas.length === 0 || largas.length === 0) break;
c1 = media(cortas);
c2 = media(largas);
}
if (cortas.length < 3 || largas.length < 3 || c2 < 1.8 * c1) {
aviso.textContent = "Haz al menos 3 pulsaciones claramente cortas y 3 claramente largas, y vuelve a calibrar.";
return;
}
const umbral = (c1 + c2) / 2; // justo en medio de los dos centros
aviso.textContent = "Centro de las cortas: " + Math.round(c1) + " ms. Centro de las largas: " +
Math.round(c2) + " ms. Umbral nuevo: " + Math.round(umbral) + " ms.";
enviarOrden("U" + Math.round(umbral / 2)); // la unidad es la mitad del umbral
};
// La tabla de referencia del código Morse (solo para consultar)
const MORSE = {A:".-",B:"-...",C:"-.-.",D:"-..",E:".",F:"..-.",G:"--.",H:"....",I:"..",J:".---",K:"-.-",L:".-..",
M:"--",N:"-.",O:"---",P:".--.",Q:"--.-",R:".-.",S:"...",T:"-",U:"..-",V:"...-",W:".--",X:"-..-",Y:"-.--",Z:"--..",
0:"-----",1:".----",2:"..---",3:"...--",4:"....-",5:".....",6:"-....",7:"--...",8:"---..",9:"----."};
document.getElementById("tabla").innerHTML = Object.entries(MORSE)
.map(([letra, codigo]) => letra + " " + codigo.replace(/\./g, "·").replace(/-/g, "—")).join("<br>");
/* ============================================================
PARTE 3 · EL BLUETOOTH (casi igual que en el chisme 35; no hace falta tocarlo)
============================================================ */
const SERVICIO = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
const UUID_RX = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"; // aquí ESCRIBIMOS las órdenes
const UUID_TX = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"; // por aquí RECIBIMOS los avisos
let dispositivo, canalOrdenes;
const estado = document.getElementById("estado");
const espera = ms => new Promise(r => setTimeout(r, ms));
async function enviarOrden(orden) {
if (!canalOrdenes) return;
const datos = new TextEncoder().encode(orden);
try {
if (canalOrdenes.writeValueWithoutResponse) await canalOrdenes.writeValueWithoutResponse(datos);
else await canalOrdenes.writeValue(datos);
} catch (e) { estado.textContent = "No se pudo enviar: " + e.message; }
}
async function abrirCanales() {
for (let intento = 1; intento <= 3; intento++) { // a veces falla la primera vez: reintentamos
try {
const servidor = await dispositivo.gatt.connect();
await espera(600);
const servicio = await servidor.getPrimaryService(SERVICIO);
canalOrdenes = await servicio.getCharacteristic(UUID_RX);
const canalAvisos = await servicio.getCharacteristic(UUID_TX);
canalAvisos.addEventListener("characteristicvaluechanged", e => alRecibirAviso(new TextDecoder().decode(e.target.value)));
await canalAvisos.startNotifications();
estado.textContent = "Conectada a " + dispositivo.name + ". Ya puedes pulsar el botón de la placa.";
for (const id of ["botonVoz", "botonSonido", "botonLeer", "botonBorrar"])
document.getElementById(id).disabled = false;
enviarOrden("?"); // "?" = dime qué unidad usas
dibujarHistograma();
return;
} catch (e) {
if (intento === 3) throw e;
await espera(800);
}
}
}
document.getElementById("botonConectar").onclick = async function () {
if (!navigator.bluetooth) { estado.textContent = "Este navegador no permite Bluetooth. Usa Chrome."; return; }
// Al pulsar este botón el navegador nos deja hablar y sonar: dejamos la voz y el audio preparados.
if (window.speechSynthesis) speechSynthesis.speak(new SpeechSynthesisUtterance(""));
if (!audio && window.AudioContext) audio = new AudioContext();
try {
estado.textContent = "Buscando la placa...";
dispositivo = await navigator.bluetooth.requestDevice({ acceptAllDevices: true, optionalServices: [SERVICIO] });
dispositivo.addEventListener("gattserverdisconnected", () => {
estado.textContent = "Conexión perdida. Pulsa «Conectar la placa».";
canalOrdenes = null;
for (const id of ["botonVoz", "botonSonido", "botonLeer", "botonBorrar", "botonCalibrar"])
document.getElementById(id).disabled = true;
});
await abrirCanales();
} catch (e) {
estado.textContent = "Error: " + e.message + " (elige «Heltec-Telegrafo» en la lista).";
}
};
dibujarHistograma();
</script>
</body>
</html>
Lee y predice
- Si la placa manda
P.320y despuésP-950, ¿cuántas pulsaciones cortas y cuántas largas habrá en el resumen? - ¿Por qué la página calcula el umbral con dos grupos y no con la media de todas las duraciones?
- Si pulsas solo puntos y rayas muy parecidas entre sí, ¿por qué el botón Calibrar pide pulsaciones «claramente cortas y claramente largas»?
Soluciones: 1) Una corta y una larga, si la unidad es 250 ms (umbral de 500 ms). 2) Porque la media de todas caería en un tiempo intermedio que casi nadie pulsa, y habría que decidir a qué grupo pertenece cada pulsación; el centro de cada grupo se calcula por separado. 3) Porque si no hay dos grupos separados, no hay un umbral que tenga sentido: cualquier corte dividiría una sola nube de tiempos.

Cómo probarlo
- Enciende la placa sin tocar el botón. La pantalla dice «TELEGRAFO – esperando» y un guión bajo.
- Abre la página en Chrome, pulsa «Conectar la placa» y elige «Heltec-Telegrafo».
- Escribe una E: una sola pulsación corta. Espera menos de un segundo sin tocar nada. La pantalla escribe E y el móvil la dice.
- Escribe una T: una sola pulsación larga, de casi un segundo.
- Escribe SOS: tres cortas, tres largas y tres cortas. Entre pulsaciones deja menos de tres cuartos de segundo; al acabar cada letra, espera.
- Escribe HOLA y espera dos segundos al final. La placa añade un espacio cuando acaba la palabra.
Al pulsar, el móvil pita: corto para el punto, largo para la raya. Así quien no ve sabe qué ha entendido la placa antes de que salga la letra.
Si algo no va
- No pasa nada al pulsar: mira el Monitor Serie. Si no sale «pulso», el botón no está en el GPIO 36 de tu placa. Usa el programa de prueba de arriba.
- Todo salen puntos: tus rayas son demasiado cortas. Mantén pulsado más tiempo o usa el botón Calibrar.
- Salen letras partidas: has dejado más de 750 ms entre dos pulsaciones de la misma letra, y la placa la ha cerrado.
- Sale un interrogante: esa combinación no existe en la tabla. Mira la tabla de la página.
- No oyes nada: sube el volumen del móvil y comprueba que el botón de voz no diga «Voz: no».
Qué estamos midiendo realmente
En este Morse con ESP32, la placa mide una sola cosa: cuánto tiempo está pulsado el botón, en milisegundos. Eso lo mide bien, porque cuenta el tiempo ella misma con millis(). El Bluetooth no estropea la medida: la duración se mide en la placa, antes de enviarla.
Pero la placa no mide lo que tú querías decir. Tú querías hacer un punto o una raya; ella solo ve un tiempo. Pasar del tiempo a «punto» o «raya» es una decisión, no una medida. Medir no significa medir bien lo que importa.
Dos grupos, no dos valores
Cuando pulsas muchas veces, las duraciones no salen todas iguales. Salen dos nubes: una de tiempos cortos y otra de tiempos largos, con algo de variación dentro de cada una. En el histograma se ven como dos montañas.
El umbral es la línea que las separa. Si las montañas no se solapan, cualquier línea entre ellas funciona. Si se solapan, siempre habrá errores: puntos que parecen rayas, y rayas que parecen puntos.
Calibrar es repartir en grupos
El botón Calibrar busca el centro de cada nube con un método sencillo: reparte las pulsaciones entre dos centros, recalcula cada centro como la media de su grupo, y repite hasta que no cambia nada. Es una versión mínima de lo que en ciencia de datos se llama agrupamiento (clustering), y no necesita que nadie le diga qué es corto y qué es largo.
El umbral queda a medio camino entre los dos centros. Es una elección razonable, pero no la única: si te preocupa más confundir un punto con una raya que al revés, puedes moverlo.
Cada persona, su ritmo
La unidad de 250 ms equivale a unas cinco palabras por minuto, usando la palabra PARIS como referencia habitual de velocidad en Morse (50 unidades por palabra: 1200 dividido entre la unidad en milisegundos). Una persona con experiencia puede ir mucho más rápido, y una con poca movilidad en los dedos, más despacio.
Por eso el telégrafo no impone un tiempo: lo aprende de quien lo usa. Y si te cansas, el ritmo cambia: tus datos de las primeras pulsaciones no son los de las últimas.
Qué sale del dispositivo
Nada. Los mensajes viven solo en la memoria de la placa y de la página, y viajan por Bluetooth a corta distancia. Una excepción posible: la voz la genera normalmente el propio móvil, pero si has elegido una voz en línea en tu móvil, el texto podría salir hacia el servicio de voz.
Para el aula
Este chisme de Morse con ESP32 sirve para ponerse en el lugar de quien no puede usar una pantalla. Pero ojo: se trata de entender un problema de accesibilidad, no de imitar a nadie. Quien lo prueba con los ojos cerrados hace un experimento, y las personas ciegas usan estas tecnologías todos los días con mucha práctica.
Preguntas para empezar
- ¿Qué variable mide la placa? ¿En qué unidad?
- ¿Por qué el programa necesita dos tiempos distintos: uno para decidir punto o raya, y otro para decidir cuándo acaba una letra?
- ¿Qué error puede cometer el sistema? ¿Quién lo comete: la placa o la persona?
- ¿Por qué dos compañeros necesitan umbrales distintos?
Cinco actividades
- Mis dos grupos. Cada alumno escribe SOS diez veces y mira su histograma. ¿Se ven dos montañas? ¿Cuánto se separan? Anotad el centro de cada una.
- Dos personas, dos umbrales. Cada uno pulsa Calibrar y apunta su umbral. Comparad: ¿quién es más rápido? ¿Cómo lo sabéis: por el umbral, por la unidad, por las letras por minuto?
- Sin mirar. Escribe tu nombre con los ojos cerrados, guiado solo por los pitidos y la voz del móvil. Contad cuántas letras salen bien a la primera.
- Un umbral, dos errores. Cambia el valor de UNIDAD en el programa (por ejemplo, 120, 250 y 400 ms) y escribe diez veces la misma frase. Cada vez, cuenta cuántos puntos se leen como rayas y cuántas rayas como puntos. Dibuja los errores contra la unidad.
- Mensaje de equipo. Un grupo envía un mensaje corto al otro sin hablar. El segundo grupo solo oye la voz del móvil. Medid cuánto tarda y cuántos errores hay.
Para pensar
Si una persona tiene temblor en las manos, ¿qué cambiarías en el programa? ¿Qué haría falta medir para saber si un telégrafo de un solo botón le sirve? ¿Y si en vez de un botón usara el movimiento de la cabeza?
Retos
- Haz que funcione. Escribe E, T y SOS, y comprueba que el móvil las dice.
- Modifica algo. Cambia
2 * UNIDADpor otro número, o la frecuencia del pitido en la página (700 Hz). ¿Qué notas? - Añade un botón. Conecta un segundo botón (el de cuatro patas, entre un GPIO libre y GND) y haz que sirva para borrar la última letra.
- Guarda los datos. Añade a la página un botón que descargue las duraciones en un archivo CSV, con una columna que diga si cada una fue punto o raya.
- Representa los datos. Dibuja las duraciones en el orden en que ocurrieron. ¿Se ve que te cansas? ¿Se ve que mejoras?
- Automatiza el proceso. Haz que la página calibre sola cuando haya diez pulsaciones y vuelva a calibrar cada veinte.
- Haz que otro dispositivo participe. Envía el mensaje a otra placa por LoRa y pónselo también en su pantalla. Puedes cifrarlo y firmarlo como hicimos en «El mensaje verificado» (chisme 33), que encuentras en la colección de chismes.
- Conviértelo en investigación. ¿Mejora la velocidad con la práctica? Cada alumno entrena cinco minutos al día durante una semana. Anota letras por minuto y errores, formula una hipótesis, dibuja los datos y di qué puedes concluir y qué no.
Qué hemos aprendido
Hemos construido un telégrafo: Morse con ESP32, usando solo el botón que ya trae la placa. Hemos aprendido a leer un botón de verdad: apuntar su estado de reposo, ignorar los rebotes y medir cuánto dura cada pulsación.
También hemos aprendido que «corto» y «largo» no son valores exactos, sino dos grupos de tiempos, y que el umbral que los separa se puede calcular con los datos de cada persona.
Y hemos dado la misma información por dos canales: la pantalla para quien ve y la voz y los pitidos para quien escucha. Esa es la idea de fondo de la accesibilidad.
En Sierra Bermeja, Justicia Ciega apoyó el dedo en el botón de la placa. Pulso largo, pulso largo, pulso corto, pulso largo: Q. Luego U, I, E, T, O, S.
En el móvil del Lince, una voz tranquila fue diciendo: «Q. U. I. E. T. O. S.». A unos cien metros, el resto del equipo se quedó inmóvil. La cabra montés subió el barranco con su cría, sin prisa y sin susto.
—Un solo botón —murmuró la Gata Montesa.
—Yo tenía cincuenta —dijo el Lince.
—Ya lo sé, Lince. Ya lo sé.
Para seguir investigando
- Todos los chismes de la serie están en la colección de Chismes del Lince.
- Para recordar cómo se conecta la placa por Bluetooth, empieza por el chisme 25: El letrero del Lince.
- Otro chisme con la Heltec que también usa colores y avisos es el chisme 27: Semáforo Bluetooth con Heltec.
- El Morse internacional está definido en la Recomendación UIT-R M.1677-1, de la Unión Internacional de Telecomunicaciones.
- Las limitaciones de los pines 34 a 39 del ESP32 están en la documentación de GPIO de Espressif.
- Para ver otras placas y documentación del fabricante, visita la web de Heltec.
Este chisme es un prototipo educativo. No es un dispositivo de comunicación asistiva certificado y no sustituye a las ayudas técnicas profesionales.
Etiqueta:accesibilidad, Bluetooth, botón, código Morse, esp32, Heltec LoRa 32, histograma, millis, Morse, TTGO LoRa32, Web Bluetooth



