
Chismes del Lince 39: El collar del Lince
La tormenta de la tarde había dejado a medio Sierra Bermeja sin luz, pero la cueva del Lince tenía una tablet con batería y un programa de la tele que a la Gata Montesa le estaba empezando a doler.
En la pantalla, tres presentadores con linterna recorrían una casa a oscuras y sostenían un aparatito que pitaba.
—¡Se acerca! —susurraba uno—. ¡Mirad cómo suben los pitidos! ¡Hay una presencia aquí!
—Hay un cable detrás de esa pared —dijo la Gata sin levantar la vista de la tablet—. Eso que lleva en la mano es un medidor de campos electromagnéticos, y pita con cualquier instalación eléctrica. No hay ni una sola prueba científica de que ahí haya un fantasma.
—Pues a mí me da un yuyu… —admitió el Lince, que se había tapado con una manta hasta las orejas.
—Lince, te asusta un aparato que suena más rápido cuando algo está cerca. Es justo lo que hace un detector de verdad. Lo que no tiene es nada que detectar.
La voz de Justicia Ciega sonó desde la entrada, donde llevaba un rato escuchando sin que nadie lo hubiera oído llegar.
—Un aparato que pita más rápido cuanto más cerca está algo… —dijo, pensativo, apoyado en su bastón—. Para mí eso no es una tontería. Es un mapa que se oye.
—Para ti, quizá —dijo la Gata—. Para ellos es un truco de televisión.
—¿Y para qué serviría de verdad? —preguntó el Lince, asomando la nariz de la manta.
Justicia Ciega sonrió.
—Esta mañana he oído pasar una manada de cabras monteses por el barranco. Pero no sé cuántas eran, ni si fue por el mismo sitio que la semana pasada. Si pudiéramos seguir a los animales que marcamos con un collar y oír cuándo se acercan…
—Los linces, las cabras, los búhos —siguió la Gata, ya con los ojos brillando—. Aprender por dónde se mueven de verdad, con datos. ¡Eso sí es ciencia!
—Perfecto. Compro uno de esos detectores de fantasmas —dijo el Lince.
—Pita con el microondas, Lince.
—Pues entonces… ¿construyo un satélite?
La Gata sacó de la mochila una placa Heltec.
—Construiremos un collar. Una placa y un móvil. Cuanto más cerca esté el móvil, más rápido pita.
Introducción
Hoy construimos un rastreador Bluetooth ESP32: un «collar» que sabe, más o menos, a qué distancia está tu móvil y te lo cuenta con pitidos. La placa mide lo fuerte que le llega la señal Bluetooth del móvil, calcula una distancia aproximada y la escribe en su pantalla. El móvil pita más rápido y más fuerte cuanto más te acercas.
No necesitas nada más: una sola placa y el móvil, solo Bluetooth. No hay sensores extra ni una segunda placa.
La cadena es corta: señal Bluetooth → intensidad (RSSI) → distancia aproximada → pitidos. Y por el camino aparecen tres ideas importantes: la señal se debilita con la distancia, una distancia calculada con una señal es siempre aproximada, y para detectar algo de verdad hay que saber qué se está detectando.
Por qué este chisme
Los programas de cazafantasmas de la tele usan aparatos que pitan más rápido cuando «algo» se acerca. No hay ninguna prueba científica de que ese «algo» sea un fantasma: esos aparatos reaccionan a cosas corrientes, como cables, motores o móviles.
La tecnología, en cambio, sí sirve. Un detector que se acelera al acercarse es una idea útil si sabemos qué estamos detectando y podemos comprobar que acierta.
En Sierra Bermeja, esa idea sirve para el trabajo de campo: seguir a los animales que se marcan (linces, cabras monteses, búhos) y aprender por dónde se mueven. Nuestro collar es un modelo a pequeña escala de esa técnica, la radiotelemetría.
Además, es la primera vez en la serie que usamos la intensidad de señal (RSSI) para estimar una distancia. Y nos deja un trabajo para más adelante: detectores de movimiento para saber cuándo pasa algo por un sendero.
Cómo funciona el rastreador Bluetooth
El Lince plantea lo de siempre: ¿hace falta un GPS para saber si el collar está cerca? La Gata lo corta. La placa ya tiene Bluetooth, y una señal de radio llega más débil cuanto más lejos está quien la emite. Medir esa debilidad es medir, de forma indirecta, la distancia.
La intensidad de señal (RSSI)
El chip Bluetooth mide la potencia con la que le llega la señal y la entrega como RSSI, en dBm. Son números negativos: -50 es una señal fuerte (móvil muy cerca) y -90 es muy débil.
Los dBm son una escala logarítmica. Cada 10 dB menos, la potencia que llega es diez veces menor. Por eso hacen falta pocos dB de diferencia para pasar de «pegado» a «muy lejos».
Cuándo puede preguntar la placa
La placa solo puede leer la señal de un móvil con el que está conectada. Por eso la página del móvil se conecta por Bluetooth al collar, como en los chismes anteriores, y la placa le pregunta al chip unas 6 veces por segundo: «¿con qué potencia me llega este móvil?».
De dBm a metros
Para pasar de potencia a distancia usamos un modelo muy sencillo:
d = 10^{(A - RSSI) / (10 \cdot N)}
Aquí d es la distancia en metros, RSSI la señal de ahora y A la señal que llega a 1 metro, que se calibra. N dice cuánto se debilita la señal: vale 2 en espacio abierto y suele ser mayor, entre 2,5 y 4, dentro de edificios.
Con A = -59 dBm y N = 2,5, la fórmula da esto:
| RSSI (dBm) | Distancia que calcula la fórmula (m) |
|---|---|
| -59 | 1,0 |
| -65 | 1,7 |
| -70 | 2,8 |
| -75 | 4,4 |
| -80 | 6,9 |
| -85 | 11,0 |
Fíjate en que 5 dB de diferencia suponen 1 m entre -65 y -70, pero 4 m entre -80 y -85. Cuanto más lejos, peor se distingue una posición de otra.
La señal tiembla: hay que suavizarla
La señal de radio rebota en paredes, suelos y personas, y llega mezclada consigo misma. Una medida suelta puede variar varios dB sin que muevas el móvil. La placa aplica el mismo filtro de suavizado que en el theremin: cada medida nueva solo mueve un poco el valor anterior.
La placa envía a la página las dos cosas, la señal suavizada y la que llega sin suavizar. Así la gráfica de la página permite ver cuánto ayuda el filtro.

Qué necesitamos
Lo mínimo de la serie: no hay que conectar ni soldar nada.
- Una placa Heltec o TTGO LoRa32 (ESP32), con su cable USB.
- Un móvil Android o un ordenador con Chrome (el navegador que permite Web Bluetooth).
- En el Arduino IDE: la placa «ESP32 Dev Module» y las bibliotecas Adafruit SSD1306 y Adafruit GFX, las mismas de los chismes anteriores.
- El paquete de placas ESP32 de Espressif en la versión 2.0.x. El programa usa una función de Bluetooth que en otras versiones puede cambiar.
- Una cinta métrica o una regla larga, para calibrar y comprobar.
Si el móvil no tiene la página a mano, abre la del chisme desde el enlace de esta entrada. También puedes usar el botón «Probar sin placa» para oír cómo suenan los pitidos antes de conectar nada.
Construcción
Este chisme no tiene montaje: el collar es la propia placa. Todo el trabajo está en el programa y en la calibración.
- En el Arduino IDE abre el programa de la placa (está en la sección siguiente), elige «ESP32 Dev Module» y súbelo.
- Al terminar, la pantalla de la placa dice «COLLAR – esperando».
- Abre la página del móvil en Chrome, pulsa Conectar el collar y elige «Heltec-Collar» en la lista.
- La pantalla pasa a «COLLAR – conectado» y aparece una distancia con el signo ~, que significa «aproximadamente».
- Calibra: pon el móvil a 1 metro de la placa, mide con la cinta, pulsa Calibrar a 1 metro y no te muevas 3 segundos.
La calibración es el paso más importante. Cada móvil emite con una potencia distinta y cada sitio es diferente, así que el valor de A (la señal a 1 metro) no es el mismo para todos. Sin calibrar, la distancia puede salir muy lejos de la real.
Para ver los números, abre en el Arduino IDE el Monitor Serie a 115200 baudios. Escribe la señal sin suavizar, la suavizada y la distancia cada vez que llega una medida.
Haz la calibración con el móvil en la mano y orientado como lo vas a llevar. Si lo metes en un bolsillo o lo tapas con la mano, el cuerpo absorbe parte de la señal y todo cambia.


El código de la placa
El programa tiene cuatro partes y solo las dos primeras son el chisme. La PARTE 1 convierte la señal en distancia con la fórmula y el suavizado. La PARTE 2 es la forma de preguntarle al chip Bluetooth con qué potencia le llega el móvil. La pantalla (PARTE 3) y el Bluetooth (PARTE 4) puedes copiarlos sin leerlos.
Antes de subirlo, lee y predice (apunta la respuesta y luego comprueba):
- Si el móvil se aleja y la señal pasa de -60 a -80 dBm, ¿la distancia calculada se duplica, se triplica o se multiplica por más?
- Si pones
ALFA = 1, ¿qué le pasa a la distancia de la pantalla cuando no mueves el móvil? - Si cambias
EXPONENTEde 2,5 a 4 sin mover el móvil, ¿la distancia calculada sube o baja?
/*
Chisme 39 · El collar del Lince
La placa hace de "collar": mide lo FUERTE que le llega la senal Bluetooth de tu
movil (RSSI), calcula una distancia APROXIMADA y la escribe en su pantalla.
El movil pita mas rapido y mas fuerte cuanto mas cerca estas.
Este programa tiene 4 partes. Solo las PARTES 1 y 2 son el chisme:
PARTE 1 · DE SENAL A DISTANCIA <- LEE ESTA. La formula y el suavizado.
PARTE 2 · LEER LA SENAL <- LEE ESTA. Como pregunta la placa "¿cuanto me llega?".
PARTE 3 · LA PANTALLA <- NO ES NECESARIA. Solo escribe la distancia en la OLED.
PARTE 4 · EL BLUETOOTH <- Como en el chisme 38. No hace falta tocarla.
(y al final, setup() y loop())
NO HAY QUE CONECTAR NADA: solo la placa y el movil.
La distancia sale de la potencia de la senal, y esa potencia cambia con las
paredes, el cuerpo, los muebles... Por eso la distancia es solo APROXIMADA.
PUERTO SERIE: Herramientas -> Monitor Serie, a 115200 baudios.
Librerias: Adafruit SSD1306 y Adafruit GFX.
Placa en el Arduino IDE: "ESP32 Dev Module".
Pensado para el paquete de placas ESP32 de Espressif version 2.0.x.
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 · DE SENAL A DISTANCIA (lo importante)
La senal Bluetooth se debilita con la distancia. Se mide en dBm (numeros
NEGATIVOS: -50 es mas fuerte que -80). El modelo mas simple dice:
distancia = 10 ^ ( (RSSI_1M - RSSI) / (10 · EXPONENTE) )
RSSI = la senal que llega ahora (dBm)
RSSI_1M = la senal que llega a 1 metro (se calibra desde la pagina)
EXPONENTE = cuanto se debilita la senal con la distancia
(2 = espacio abierto, entre 2,5 y 4 = dentro de un edificio)
La senal de un solo instante "tiembla" mucho: la suavizamos con un filtro.
============================================================ */
float RSSI_1M = -59; // RSSI a 1 metro (valor de partida; se calibra)
float EXPONENTE = 2.5; // exponente de perdida (se puede cambiar desde la pagina)
const float ALFA = 0.25; // cuanto pesa cada medida nueva (0 a 1)
float rssiSuave = 0; // la senal ya suavizada
int rssiBruto = 0; // la ultima medida sin suavizar
bool hayMedida = false; // ¿ya tenemos alguna medida?
float distancia = 0; // distancia aproximada, en metros
void procesarMedida(int rssi) { // se llama con cada medida nueva
rssiBruto = rssi;
if (!hayMedida) { rssiSuave = rssi; hayMedida = true; } // la primera, tal cual
else rssiSuave = rssiSuave + ALFA * (rssi - rssiSuave); // las demas, suavizadas
distancia = pow(10.0, (RSSI_1M - rssiSuave) / (10.0 * EXPONENTE)); // ¡la formula!
distancia = constrain(distancia, 0.1, 30.0); // no tiene sentido pasar de ahi
Serial.println(String("bruto ") + rssiBruto + " suave " + rssiSuave + " distancia " + distancia + " m");
}
/* ============================================================
PARTE 2 · LEER LA SENAL (lo importante)
Cuando el movil esta conectado, la placa puede preguntarle al chip Bluetooth:
"¿con que potencia me llega este movil?". La respuesta tarda unos milisegundos
y llega a la funcion alEventoGap(). Aqui solo la guardamos; loop() la atiende.
============================================================ */
esp_bd_addr_t direccionMovil; // la "direccion" del movil conectado
volatile bool conectado = false; // ¿hay un movil conectado?
volatile bool hayRssiNuevo = false; // ¿ha llegado una medida nueva?
volatile int rssiNuevo = 0; // y cuanto vale
void alEventoGap(esp_gap_ble_cb_event_t evento, esp_ble_gap_cb_param_t *param) {
if (evento == ESP_GAP_BLE_READ_RSSI_COMPLETE_EVT &&
param->read_rssi_cmpl.status == ESP_BT_STATUS_SUCCESS) {
rssiNuevo = param->read_rssi_cmpl.rssi; // dBm, un numero negativo
hayRssiNuevo = true;
}
}
/* ============================================================
PARTE 3 · LA PANTALLA (NO ES NECESARIA: el collar funciona sin ella)
Escribe la distancia aproximada y la senal 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);
void pintarPantalla() {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(1);
display.setCursor(0, 0);
display.print(conectado ? "COLLAR - conectado" : "COLLAR - esperando");
if (conectado && hayMedida) {
display.setTextSize(3);
display.setCursor(0, 14);
display.print(String("~") + String(distancia, 1)); // el ~ significa "aproximadamente"
display.setTextSize(1);
display.print(" m");
display.setCursor(0, 42);
display.print(String("Senal ") + (int)rssiSuave + " dBm");
display.setCursor(0, 54);
display.print(String("A=") + (int)RSSI_1M + " N=" + String(EXPONENTE, 1));
} else {
display.setTextSize(1);
display.setCursor(0, 24);
display.print("Abre la pagina y");
display.setCursor(0, 36);
display.print("conecta el collar");
}
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 38)
La placa se anuncia como "Heltec-Collar". Tiene dos "buzones":
TX: por aqui la placa AVISA a la pagina:
"D2.4,-61,-64" = distancia 2,4 m, senal suavizada -61 dBm, senal sin suavizar -64 dBm
"A-59" = el valor de RSSI_1M (senal a 1 m) "N2.5" = el valor de EXPONENTE
RX: por aqui LLEGAN las ordenes de la pagina:
"K" = calibra: apunta la senal actual como la de 1 metro
"N3.0" = cambia EXPONENTE a 3,0 "?" = dime A y N
============================================================ */
#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, esp_ble_gatts_cb_param_t *param) {
memcpy(direccionMovil, param->connect.remote_bda, sizeof(esp_bd_addr_t)); // anotamos quien es
hayMedida = false;
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] == 'K' && hayMedida) RSSI_1M = rssiSuave; // K = esta es la senal a 1 metro
if (orden[0] == 'N') { // N3.0 = cambia el exponente
float nuevo = orden.substring(1).toFloat();
EXPONENTE = constrain(nuevo, 1.5, 4.5);
}
avisar(String("A") + (int)RSSI_1M); // contestamos siempre con A y N
delay(30);
avisar(String("N") + String(EXPONENTE, 1));
}
void arrancarBluetooth() {
BLEDevice::init("Heltec-Collar");
BLEDevice::setCustomGapHandler(alEventoGap); // para enterarnos de las medidas de senal
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();
arrancarBluetooth();
pintarPantalla();
Serial.println("Listo. Conecta el movil desde la pagina.");
}
unsigned long ultimaPregunta = 0;
unsigned long ultimaPantalla = 0;
void loop() {
if (hayOrden) { // ¿ha llegado una orden del movil?
hayOrden = false;
atenderOrden(ordenPendiente);
}
if (conectado && millis() - ultimaPregunta >= 150) { // cada 150 ms preguntamos por la senal
ultimaPregunta = millis();
esp_ble_gap_read_rssi(direccionMovil);
}
if (hayRssiNuevo) { // ¿ha llegado la respuesta?
hayRssiNuevo = false;
procesarMedida(rssiNuevo);
avisar(String("D") + String(distancia, 1) + "," + (int)rssiSuave + "," + rssiBruto);
}
if (millis() - ultimaPantalla >= 250) { // la pantalla se refresca mas despacio
ultimaPantalla = millis();
pintarPantalla();
}
delay(2);
}
La página del móvil
Guárdala como collar.html y ábrela con Chrome. En el código, la PARTE 1 convierte la distancia en un pitido (más rápido, más fuerte y más agudo cuanto más cerca). Las partes 2 y 3 (pantalla y Bluetooth) no hace falta leerlas.
La página tiene un botón Probar sin placa. Simula la señal con ruido, aplica el mismo suavizado y la misma fórmula que la placa y te deja mover una «distancia real» con un deslizador. Así puedes comparar lo que la fórmula cree con la verdad, algo que en el campo casi nunca se puede.
Lee y predice:
- ¿Qué pitará más rápido, el collar a 2 m o a 8 m? ¿Y qué sonará más agudo?
- Si cambias
DIST_LEJOSde 10 a 5, ¿a partir de qué distancia el pitido es ya el más lento? - En la simulación, pon la distancia real en 12 m. ¿La página mostrará exactamente 12 m? ¿Por qué sí o por qué no?
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chisme 39 · El collar del Lince</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;
--bruto:#9aa9a3; --linea:#7b3fa0; --frio:#3b7dd8; --caliente:#d63a2f; --aviso:#ffd23f; }
@media (prefers-color-scheme: dark) {
:root { --fondo:#0f1a17; --texto:#e8f1ed; --suave:#9db3aa; --verde:#5fc4a2; --linea:#c79bff; }
}
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; }
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; }
/* La distancia, en grande */
#distancia { font-size:4rem; font-weight:700; min-height:5rem; margin:.8rem auto 0; color:var(--linea); }
#distancia.callada { color:var(--suave); font-size:2rem; padding-top:1.6rem; }
#senal { min-height:1.6rem; }
/* La barra: azul = lejos, roja = cerca */
#barra { height:1.1rem; max-width:30rem; margin:.5rem auto; border:2px solid var(--suave);
border-radius:.6rem; overflow:hidden; }
#relleno { height:100%; width:0; background:var(--frio); transition:width .2s, background .2s; }
canvas { width:100%; max-width:34rem; height:9rem; display:block; margin:.6rem auto;
border-bottom:2px solid var(--suave); }
#simulado { display:none; background:var(--aviso); color:#10201c; font-weight:700; padding:.6rem 1rem;
border-radius:.9rem; max-width:34rem; margin:.8rem auto; }
#simulado.visible { display:block; }
.fila { max-width:34rem; margin:.6rem auto; display:flex; align-items:center; gap:.6rem;
justify-content:center; flex-wrap:wrap; }
.fila label { color:var(--suave); }
input[type=range] { width:12rem; }
#datos { font-family:ui-monospace, monospace; font-size:.95rem; color:var(--suave); }
</style>
</head>
<body>
<h1>El collar del Lince</h1>
<p>El collar es la placa. Acércate con el móvil y pitará más rápido y más fuerte.</p>
<p id="estado">Sin conectar.</p>
<button id="botonConectar">Conectar el collar</button>
<button id="botonSimular">Probar sin placa</button>
<div id="simulado">DATOS SIMULADOS: mueve el deslizador «Distancia real simulada». No viene de la placa.</div>
<!-- aria-live: los lectores de pantalla anuncian solos cada cambio -->
<div id="distancia" class="callada" aria-live="polite">Sin señal</div>
<div id="senal"></div>
<div id="barra"><div id="relleno"></div></div>
<canvas id="grafica" width="680" height="180" aria-label="Gráfica de la señal: gris = medida bruta, morado = medida suavizada"></canvas>
<p>Gris: la señal tal como llega (tiembla mucho). Morado: la señal suavizada.</p>
<p id="datos">Sin datos.</p>
<div class="fila" id="filaSimulada" style="display:none">
<label for="distanciaSimulada" id="etiquetaSimulada">Distancia real simulada: 5,0 m</label>
<input type="range" id="distanciaSimulada" min="3" max="150" value="50">
</div>
<div class="fila">
<label for="exponente" id="etiquetaExponente">Exponente N = 2,5</label>
<input type="range" id="exponente" min="15" max="45" value="25">
</div>
<div class="fila">
<label for="volumen">Volumen</label>
<input type="range" id="volumen" min="0" max="100" value="50">
</div>
<button id="botonCalibrar" disabled>Calibrar a 1 metro</button>
<button id="botonSilencio">Silenciar</button>
<button id="botonCSV" disabled>Descargar CSV</button>
<p id="mensaje"></p>
<script>
/* ============================================================
PARTE 1 · DE LA DISTANCIA AL PITIDO (léela)
La placa nos AVISA unas 6 veces por segundo con mensajes cortos:
"D2.4,-61,-64" distancia aproximada (2,4 m), señal suavizada (-61 dBm) y señal sin suavizar (-64 dBm)
"A-59" la señal que llega a 1 metro (la que calibramos)
"N2.5" el exponente N del modelo
Aquí convertimos la distancia en un pitido: cuanto más cerca, MÁS RÁPIDO, MÁS FUERTE y MÁS AGUDO.
============================================================ */
const DIST_CERCA = 0.5; // a esta distancia (m) o menos, el pitido es casi continuo
const DIST_LEJOS = 10; // a esta distancia (m) o más, el pitido es lento y flojito
let distancia = null; // la última distancia recibida (m)
let ultimoDato = 0; // cuándo llegó (para saber si la placa sigue ahí)
// Cercanía: 0 = lejos, 1 = muy cerca.
function cercania(d) {
return Math.min(1, Math.max(0, (DIST_LEJOS - d) / (DIST_LEJOS - DIST_CERCA)));
}
function intervaloMs(c) { return 1500 - c * 1400; } // de 1500 ms (lejos) a 100 ms (cerca)
function volumenPitido(c) { return 0.08 + 0.42 * c; } // de flojito a fuerte
function tonoHz(c) { return 600 + 500 * c; } // de 600 Hz (grave) a 1100 Hz (agudo)
// Un pitido: un oscilador que suena 80 ms y se apaga solo.
let audio = null, silencio = false, volumenGeneral = 0.5;
function prepararAudio() { // hay que llamarla al pulsar un botón: el navegador lo exige
if (!audio && window.AudioContext) audio = new AudioContext();
if (audio && audio.state === "suspended") audio.resume();
}
function pitar(c) {
if (!audio) return;
const t = audio.currentTime;
const oscilador = audio.createOscillator();
const ganancia = audio.createGain();
oscilador.frequency.value = tonoHz(c);
oscilador.connect(ganancia);
ganancia.connect(audio.destination);
ganancia.gain.setValueAtTime(volumenPitido(c) * volumenGeneral, t);
ganancia.gain.exponentialRampToValueAtTime(0.001, t + 0.08);
oscilador.start(t);
oscilador.stop(t + 0.09);
}
// El bucle de pitidos: pita y se vuelve a programar según la distancia de ESE momento.
let contadorPitidos = 0;
function ciclo() {
const hayDatos = distancia !== null && performance.now() - ultimoDato < 2000;
if (hayDatos && !silencio) { pitar(cercania(distancia)); contadorPitidos++; }
setTimeout(ciclo, hayDatos ? intervaloMs(cercania(distancia)) : 500);
}
ciclo();
function alRecibirAviso(texto) {
if (texto[0] === "D") { // una medida nueva
const partes = texto.substring(1).split(",");
const d = parseFloat(partes[0]), suave = parseInt(partes[1], 10), bruto = parseInt(partes[2], 10);
if (isNaN(d) || isNaN(suave) || isNaN(bruto)) return; // algo raro: lo ignoramos
distancia = d;
ultimoDato = performance.now();
mostrar(d, suave, bruto);
return;
}
const numero = parseFloat(texto.substring(1));
if (isNaN(numero)) return;
if (texto[0] === "A") a1metro = numero; // la placa nos cuenta su calibración
if (texto[0] === "N") { // y su exponente
exponente = numero;
document.getElementById("exponente").value = Math.round(numero * 10);
document.getElementById("etiquetaExponente").textContent = "Exponente N = " + numero.toFixed(1).replace(".", ",");
}
}
/* ============================================================
PARTE 2 · LA PANTALLA Y LOS BOTONES (NO ES NECESARIA para que pite)
Dibuja la distancia, la gráfica y los datos; calibra; simula; descarga el CSV.
============================================================ */
let a1metro = -59, exponente = 2.5;
const historialBruto = [], historialSuave = []; // las últimas 150 medidas
const filas = [["ms", "distancia_m", "rssi_suave_dBm", "rssi_bruto_dBm"]];
function mostrar(d, suave, bruto) {
const marcador = document.getElementById("distancia");
marcador.classList.remove("callada");
marcador.textContent = "≈ " + d.toFixed(1).replace(".", ",") + " m";
document.getElementById("senal").textContent = "señal " + suave + " dBm";
const c = cercania(d);
const relleno = document.getElementById("relleno");
relleno.style.width = Math.round(c * 100) + "%";
relleno.style.background = c > 0.5 ? "var(--caliente)" : "var(--frio)";
document.getElementById("datos").textContent =
"bruto " + bruto + " dBm · suave " + suave + " dBm · A " + a1metro + " dBm · N " + exponente.toFixed(1);
historialBruto.push(bruto); historialSuave.push(suave);
if (historialBruto.length > 150) { historialBruto.shift(); historialSuave.shift(); }
if (filas.length < 20000) filas.push([Math.round(performance.now()), d.toFixed(1), suave, bruto]);
dibujar();
}
function dibujar() {
const lienzo = document.getElementById("grafica");
const c = lienzo.getContext("2d");
const estilo = getComputedStyle(document.body);
c.clearRect(0, 0, lienzo.width, lienzo.height);
const minimo = -110, maximo = -30; // la escala vertical, en dBm
const paso = lienzo.width / 150;
function linea(datos, color, ancho) {
c.beginPath(); c.strokeStyle = color; c.lineWidth = ancho;
datos.forEach((v, i) => {
const y = lienzo.height - ((v - minimo) / (maximo - minimo)) * lienzo.height;
if (i === 0) c.moveTo(0, y); else c.lineTo(i * paso, y);
});
c.stroke();
}
linea(historialBruto, estilo.getPropertyValue("--bruto"), 1.5);
linea(historialSuave, estilo.getPropertyValue("--linea"), 3);
}
document.getElementById("botonCalibrar").onclick = function () {
const mensaje = document.getElementById("mensaje");
mensaje.textContent = "Pon el móvil a 1 metro de la placa y no te muevas. Calibrando en 3 segundos...";
setTimeout(function () {
if (simulando) { a1metro = simA = simSuave; mensaje.textContent = "Calibrado: A = " + a1metro + " dBm."; return; }
enviarOrden("K"); // la placa apunta la señal de AHORA como la de 1 metro
mensaje.textContent = "Calibrado. La placa ha apuntado la señal de este momento como la de 1 metro.";
}, 3000);
};
document.getElementById("botonSilencio").onclick = function () {
silencio = !silencio;
this.textContent = silencio ? "Quitar silencio" : "Silenciar";
};
document.getElementById("volumen").oninput = function () { volumenGeneral = this.value / 100; };
let temporizadorN = null;
document.getElementById("exponente").oninput = function () {
exponente = this.value / 10;
document.getElementById("etiquetaExponente").textContent = "Exponente N = " + exponente.toFixed(1).replace(".", ",");
clearTimeout(temporizadorN); // esperamos un momento por si sigues moviendo el deslizador
temporizadorN = setTimeout(function () { if (simulando) simN = exponente; else enviarOrden("N" + exponente.toFixed(1)); }, 300);
};
document.getElementById("botonCSV").onclick = function () { // los datos se descargan EN TU móvil; no salen a ningún servidor
const texto = filas.map(f => f.join(",")).join("\n");
const enlace = document.createElement("a");
enlace.href = URL.createObjectURL(new Blob([texto], { type: "text/csv" }));
enlace.download = "collar.csv";
enlace.click();
};
// Modo SIN PLACA: simulamos lo que haría la placa. Primero la señal "de verdad" según la distancia
// (con mucho ruido), luego el mismo suavizado y la misma fórmula que en el programa de la placa.
let simulando = false, relojSimulado = null;
let simA = -59, simN = 2.5, simSuave = null;
document.getElementById("distanciaSimulada").oninput = function () {
document.getElementById("etiquetaSimulada").textContent =
"Distancia real simulada: " + (this.value / 10).toFixed(1).replace(".", ",") + " m";
};
document.getElementById("botonSimular").onclick = function () {
prepararAudio();
simulando = !simulando;
document.getElementById("simulado").classList.toggle("visible", simulando);
document.getElementById("filaSimulada").style.display = simulando ? "flex" : "none";
this.textContent = simulando ? "Dejar de simular" : "Probar sin placa";
clearInterval(relojSimulado);
if (simulando) {
simSuave = null; simA = -59; simN = 2.5; a1metro = simA; exponente = simN;
document.getElementById("botonCalibrar").disabled = false;
document.getElementById("botonCSV").disabled = false;
document.getElementById("estado").textContent = "Simulando: sin placa.";
relojSimulado = setInterval(function () {
const real = document.getElementById("distanciaSimulada").value / 10; // metros
const ruido = (Math.random() + Math.random() + Math.random() - 1.5) * 6; // unos ±4 dB de temblor
const bruto = Math.round(simA - 10 * simN * Math.log10(real) + ruido); // lo que "llegaría" a la placa
simSuave = (simSuave === null) ? bruto : simSuave + 0.25 * (bruto - simSuave);
let d = Math.pow(10, (simA - simSuave) / (10 * simN)); // la fórmula de la placa
d = Math.min(30, Math.max(0.1, d));
alRecibirAviso("D" + d.toFixed(1) + "," + Math.round(simSuave) + "," + bruto);
}, 150);
} else {
distancia = null;
document.getElementById("estado").textContent = "Sin conectar.";
}
};
/* ============================================================
PARTE 3 · EL BLUETOOTH (casi igual que en el chisme 38; 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 = "Conectado a " + dispositivo.name + ". Aléjate y acércate con el móvil.";
document.getElementById("botonCalibrar").disabled = false;
document.getElementById("botonCSV").disabled = false;
enviarOrden("?"); // "?" = dime tu A y tu N
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; }
prepararAudio(); // al pulsar este botón el navegador nos deja sonar
try {
estado.textContent = "Buscando el collar...";
dispositivo = await navigator.bluetooth.requestDevice({ acceptAllDevices: true, optionalServices: [SERVICIO] });
dispositivo.addEventListener("gattserverdisconnected", () => {
estado.textContent = "Conexión perdida. Pulsa «Conectar el collar».";
canalOrdenes = null;
distancia = null;
document.getElementById("distancia").className = "callada";
document.getElementById("distancia").textContent = "Sin señal";
document.getElementById("botonCalibrar").disabled = true;
});
if (simulando) document.getElementById("botonSimular").click(); // si estaba simulando, paramos
await abrirCanales();
} catch (e) {
estado.textContent = "Error: " + e.message + " (elige «Heltec-Collar» en la lista).";
}
};
</script>
</body>
</html>
Cómo probarlo
Primero, sin placa. Abre la página en el móvil y pulsa Probar sin placa. Mueve el deslizador de la distancia simulada: cerca, los pitidos van deprisa y fuertes; lejos, despacio y flojos. Así compruebas que el sonido funciona antes de tocar nada.
Después, con la placa. Sube el sketch, abre la página con Chrome en un móvil Android y pulsa Conectar el collar. En la pantalla de la placa debe aparecer «COLLAR – conectado» y una distancia con el signo ~.
La calibración es el paso que más se olvida:
- Deja el móvil a 1 metro de la placa, medido con un metro de verdad.
- Pulsa Calibrar a 1 metro y no muevas el móvil durante los 3 segundos de cuenta atrás.
- Ahora aléjate y acércate despacio. Mira si los metros de la pantalla se parecen a los que mides con el metro.
Si algo falla:
- No aparece «Heltec-Collar» al conectar. Comprueba que la placa está encendida y que has elegido «ESP32 Dev Module». Cierra y vuelve a abrir la página.
- No compila. Revisa que el paquete de placas ESP32 sea de la serie 2.0.x. Con la 3.x, las funciones del Bluetooth cambian de nombre.
- La distancia salta mucho. Es normal: la señal Bluetooth tiembla. Sube el filtro (baja ALFA en el sketch) o espera un par de segundos.
- A 3 metros marca 6, o al revés. Prueba otro exponente N con el deslizador y calibra de nuevo a 1 metro.
- No suena nada. Sube el volumen del móvil y toca la pantalla una vez: el navegador no deja sonar hasta que pulsas un botón.
Qué estamos midiendo realmente
La placa no mide distancia. Mide la potencia con la que le llega la señal del móvil (el RSSI, en dBm). La distancia es una cuenta que hacemos después con un modelo. Ese es el salto entre un dato y una medida: el dato es «−64 dBm»; la medida de «2,3 m» solo vale si el modelo es razonable.
Y el modelo es muy simple. Supone que la señal se debilita igual en todas direcciones, y eso casi nunca pasa. Las paredes, los muebles, tu propio cuerpo (el agua absorbe el Bluetooth) y los rebotes de la señal en el suelo y el techo cambian el RSSI sin que la distancia cambie. Con el móvil en el bolsillo y de espaldas a la placa, la distancia sale mayor.
Por eso hay tres ajustes. El filtro quita el temblor, pero a cambio responde más despacio. La calibración a 1 metro corrige que cada móvil emite con distinta potencia. Y el exponente N dice cuánto se debilita la señal en este sitio: cerca de 2 al aire libre y más alto dentro de un edificio. Dos móviles distintos, o la misma habitación con gente dentro, darán distancias distintas. Eso no es un fallo: es la naturaleza de la medida.
Medir no significa medir bien. El collar sirve para saber «cerca o lejos» y para ver si te acercas o te alejas. No sirve para decir «está a 3,2 metros».
Aquí vuelve la tele del principio. Los cazafantasmas de la televisión sacan un aparato que pita y dicen «hay algo». Pero no saben qué mide, no lo han calibrado y nunca hacen una prueba de control. Nosotros sí: sabemos que hay un emisor (el móvil), sabemos dónde está (con un metro) y comparamos lo que dice el aparato con lo que sabemos. Un detector que no puedes contrastar con algo conocido no detecta nada: pita.
Un apunte de privacidad: todo ocurre entre tu móvil y tu placa. No hay servidor, no hay internet y no se guarda nada, salvo que tú pulses Descargar CSV.
Y una advertencia sobre el trabajo real. Para seguir a un lince o a una cabra de verdad se usan collares con GPS o radio de mucho más alcance. El Bluetooth llega a unas pocas decenas de metros en el mejor de los casos. Este chisme sirve para entender el principio, no para sustituir esos collares.
Para el aula
La mejor actividad es convertir el collar en un experimento. Por grupos, con un metro, una hoja de cálculo y un móvil por grupo.
Actividad 1 · La curva de la señal. Pon el móvil a 1, 2, 4 y 8 metros. En cada punto, espera 10 segundos y apunta el RSSI suavizado que sale en la pantalla. Haced la gráfica distancia (eje X) contra RSSI (eje Y). Preguntas: ¿es una línea recta? ¿Por qué baja más rápido al principio que al final? (Pista: la escala es logarítmica.)
Actividad 2 · Ajustar el exponente. Con esos datos, probad valores de N hasta que la distancia de la pantalla se parezca a la del metro en los cuatro puntos. ¿Sale lo mismo en el aula que en el pasillo? ¿Y en el patio?
Actividad 3 · Dos móviles, dos curvas. Repetid con otro móvil sin recalibrar. ¿Salen las mismas distancias? ¿Qué cambia al calibrar cada uno a 1 metro?
Actividad 4 · Qué se interpone. Mismo punto, misma distancia, pero con: una persona en medio, el móvil en el bolsillo, una pared, una mochila. Anotad cuánto cambia el RSSI en cada caso. Es la forma de ver con números qué es el error sistemático.
Actividad 5 · El experimento de control. Una persona esconde el móvil sin decir dónde (entre tres sitios del aula) y otra tiene que encontrarlo solo con el pitido, sin mirar. Se repite diez veces. ¿Cuántas aciertan? ¿Es mejor que el azar (1 de 3)? Así se comprueba un detector, a diferencia de lo que hacen en la tele.
Actividad 6 · Ruido y filtro. Cambiad ALFA a 0,1 y a 0,6. Con el móvil quieto, ¿cuánto varía la distancia en 30 segundos? Y al caminar, ¿cuánto tarda en reaccionar? Es el equilibrio entre estabilidad y rapidez.
Para discutir: el collar de un animal marcado, ¿qué datos nos daría en una semana? ¿Qué preguntas sobre las cabras de Sierra Bermeja podríamos contestar con ellos?
Retos
Reto 1 · Haz que funcione. Conecta el collar, calibra a 1 metro y comprueba que a 3 metros la pantalla marca algo parecido a 3.
Reto 2 · Modifica algo. Cambia en la página DIST_CERCA y DIST_LEJOS para que el pitido máximo sea a 1 metro y el mínimo a 5. Cambia también el tono: haz que sea más grave cuanto más cerca.
Reto 3 · Añade un sensor. Si tienes un sensor de temperatura o un pulsador, hazlo avisar al móvil por el mismo canal Bluetooth. Un collar que además diga «tengo calor».
Reto 4 · Guarda los datos. Pulsa Descargar CSV después de caminar por el instituto con el móvil. Abre el archivo y mira qué columnas trae.
Reto 5 · Representa los datos. Haz con una hoja de cálculo la gráfica del RSSI a lo largo del paseo. ¿Se ve el momento en que pasaste por detrás de una pared?
Reto 6 · Automatiza el proceso. Haz que la placa guarde por sí misma la distancia mínima alcanzada y la muestre en la pantalla, para saber hasta dónde se acercó el móvil.
Reto 7 · Haz que otro dispositivo participe. Con una segunda placa, haz que sea ella la que mida el RSSI de la primera. Dos collares que se detectan entre sí: el principio de la telemetría de manada.
Reto 8 · Conviértelo en una pequeña investigación. Formula una pregunta (¿cuánto atenuamos la señal con el cuerpo?), diseña el experimento con repeticiones, anota las incertidumbres y escribe una conclusión que diga qué puedes afirmar y qué no.
Qué hemos aprendido
Hemos aprendido que una placa puede «notar» lo cerca que está un móvil sin cables ni sensores extra, solo midiendo la fuerza de una señal. Hemos visto que esa fuerza (RSSI) es un dato, que la distancia es una cuenta con un modelo, y que el modelo tiene que calibrarse y contrastarse con un metro. Hemos usado un filtro para quitar el temblor y hemos convertido un número en sonido para entenderlo sin mirar la pantalla. Y sobre todo, que un aparato que pita no demuestra nada hasta que lo pruebas contra algo que ya conoces.
La tormenta ya había pasado. En la cueva volvía a haber luz y la placa pitaba sobre la piedra, cada vez más rápido cuando la Gata se acercaba con el móvil y más despacio cuando se alejaba.
—Eso lo he visto en una película —dijo el Lince, con las orejas tiesas.
—Ya —dijo la Gata Montesa—. En Aliens (1986), los marines llevan un rastreador de movimiento, el M314, que pita más rápido cuanto más cerca está lo que se mueve. En Los cazafantasmas (1984), el aparato que llaman medidor PKE hace lo mismo con los fantasmas. Y en Tiburón, la música de dos notas va más rápida cuanto más cerca está el bicho.
—¿Lo ves? Todos usan el mismo truco.
—Sí. Pero en las películas lo que hay detrás es un guion. Nosotros tenemos un emisor conocido, un metro y una tabla de datos.
Justicia Ciega, sentado junto a la entrada con el bastión entre las rodillas, sonrió.
—Yo llevo toda la vida oyendo lo que hay alrededor. Es la primera vez que un aparato me cuenta algo parecido, y encima me lo explica con un número.
—El número es aproximado —dijo la Gata.
—Lo sé. Por eso me fío más de él que de la tele.
A lo lejos, en el barranco, las cabras monteses seguían su camino. Pronto llevarían su propio collar. Y entonces, el Lince y la Gata tendrían una pregunta mejor que «¿hay algo ahí?»: «¿por dónde pasan, y a qué hora?».
Para seguir investigando
Si quieres seguir con las placas Heltec y el Bluetooth, estos chismes te sirven de base:
- Chismes del Lince 25, El letrero del Lince: https://montesteam.org/chismes-del-lince-25-el-letrero-del-lince-heltec-lora-32-bluetooth-y-movil/
- Chismes del Lince 26, Termómetro Bluetooth con Heltec y DHT22: https://montesteam.org/chismes-del-lince-26-heltec-dht22-termometro-bluetooth/
- Chismes del Lince 28, Lince seguro: https://montesteam.org/app/chisme-28-lince-seguro.html
- Todos los Chismes del Lince: https://montesteam.org/proyectos-stem-chismes-del-lince/
Fuentes:
- Heltec Product Documentation
- The Bluetooth Low Energy primer
- Exploring Bluetooth Core 5.0 – going the distance
En el próximo chisme, el collar aprenderá a detectar movimiento por sí mismo para saber cuándo pasa algo por allí.
Etiqueta:AIDARAC, Bluetooth, calibración, ciencia ciudadana, esp32, Heltec LoRa 32, RSSI, sensores, sierra bermeja, Web Bluetooth


