Chisme 28 — Lince Seguro
GPS para localizar y proteger al lince ibérico.
Este proyecto de rastreador GPS es el Chisme 28: una placa Heltec LoRa 32 con un GPS (la araña) manda su posición por radio a otra Heltec (la base), que la pasa por Bluetooth al móvil. En la pantalla del móvil verás la latitud y la longitud de la araña y un radar con la distancia y una flecha que apunta hacia ella. No necesita mapa y, una vez abierta la página, tampoco datos. Todo funciona en el navegador, sin instalar ninguna app.
Es el cuarto Chisme con la Heltec, y el primero donde dos placas hablan entre sí por radio. En el Chisme 25 mandamos texto del móvil a la placa, en el Chisme 26 la placa midió la temperatura y en el Chisme 27 el móvil dio órdenes a un semáforo. Con este rastreador GPS juntamos todo y añadimos tres cosas nuevas: un GPS, la radio LoRa y un radar que calcula distancias.
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). Aquí solo añadimos dos librerías nuevas, el GPS y una segunda placa.
Mostrar imagen
Rastreador GPS: lo que hace Spider-Man y lo que hace el Lince
En los cómics, Spider-Man lanza unas arañitas rastreadoras a sus enemigos para saber siempre dónde están. Mola mucho, pero tiene un problema: poner un rastreador GPS a una persona sin que lo sepa es ilegal. Vulnera su intimidad y su protección de datos, y en España puede llegar a ser delito. Aunque esa persona sea «mala». Por eso, en la vida real, ni siquiera un superhéroe puede hacerlo.
Aquí es donde el Lince es mejor superhéroe que Spider-Man: usa la misma idea, pero para algo útil y legal. Los equipos que conservan el lince ibérico usan collares con GPS para saber por dónde se mueven los linces, dónde cazan, dónde descansan y qué carreteras cruzan. Con esos datos se pueden proteger mejor. Eso es Lince Seguro: localizar para proteger.
La norma de Lince de la Noche: nunca ponemos un rastreador GPS a nadie sin su permiso. Lo practicamos con objetos (una mochila, un peluche) o con animales de verdad solo si quien corresponde lo autoriza. Con el lince ibérico, que es una especie protegida, solo pueden hacerlo los equipos autorizados.
Primero protegemos al lince. Y como todo superhéroe, el Lince de la Noche tiene su código: la tecnología se usa con permiso.

Rastreador GPS: qué vamos a construir
El sistema tiene tres partes:
GPS ──► Heltec A (la araña) ──LoRa──► Heltec B (la base) ──Bluetooth──► móvil
- La araña: la placa con el GPS. Enseña la posición en su pantalla y la manda por radio cada 10 segundos.
- La base: la otra placa. Recibe la posición, la enseña en su pantalla y la reenvía por Bluetooth.
- La página del móvil: enseña la latitud y la longitud, si la posición es real o simulada, y el radar con la distancia y la flecha.
Y hay un truco para poder trabajar dentro de casa o de clase, donde el GPS no recibe señal: si no hay satélites, la araña inventa una vuelta de un minuto y avisa de que la posición es simulada. En cuanto el GPS coge señal, pasa a real sola.
Mostrar imagen
Lo que necesitas para tu rastreador GPS
- Dos placas Heltec LoRa 32 (o TTGO LoRa32), con sus antenas LoRa.
- Un módulo GPS NEO-6M, con su pequeña antena de cerámica.
- Tres cables dupont para el GPS (los que van a la araña).
- Dos cables USB de datos, o uno y una batería externa (power bank) para llevar la araña de paseo.
- Un móvil Android con Chrome, con Bluetooth y ubicación encendidos. En iPhone, el navegador Bluefy.
Una recomendación práctica: pon una etiqueta o un trozo de cinta con la palabra ARAÑA en una placa y BASE en la otra. Las dos se parecen mucho y cuesta recordar cuál lleva qué programa.
Paso 1. Mira la banda de tus placas y pon las antenas
La radio LoRa funciona en una banda de frecuencia que depende del modelo de la placa: 433, 868 o 915 MHz. En España se usa la de 868 MHz. Mira la etiqueta de tu placa o de la caja para ver cuál es la tuya. Las dos placas deben tener la misma banda.
¡Conecta la antena LoRa antes de encender la placa! Si la radio transmite sin antena, puede estropearse.
Paso 2. Instala las dos librerías nuevas
- En el Arduino IDE, abre el Administrador de bibliotecas (el icono de los libros).
- Escribe
TinyGPSPluse instala la de Mikal Hart. Es la que traduce lo que dice el GPS (TinyGPSPlus en GitHub). - Escribe
LoRae instala la de Sandeep Mistry. Es la que maneja la radio (arduino-LoRa en GitHub). - Las de la pantalla (Adafruit SSD1306 y Adafruit GFX) ya las tienes del Chisme 25.
Mostrar imagen
Paso 3. Conecta el GPS a la araña
Hazlo con la placa desconectada del USB. El módulo GPS NEO-6M tiene cuatro pines. Solo usamos tres:
| Pin del GPS | Se conecta a la Heltec |
|---|---|
| VCC | 3V3 |
| GND | GND |
| TX | GPIO 13 |
| RX | (no se usa) |
El GPS habla (por su pin TX) y la placa escucha (por el GPIO 13). Como la placa nunca le manda nada al GPS, el pin RX del GPS se queda sin conectar.
Mostrar imagen
Dos 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.
- Conecta los cables directamente, sin breadboard, como en el Chisme 27.
Paso 4. Sube el programa de la araña
- Archivo → Nuevo sketch y borra todo lo que haya.
- Copia el programa de la araña (sección «Código 1», al final de esta página) y pégalo. Guárdalo como
chisme28_arana. - Elige la placa ESP32 Dev Module y el puerto de la placa ARAÑA.
- Si tu placa es de 433 o 915 MHz, cambia
BANDAen la parte de arriba del programa. - Pulsa Subir. Si se queda en
Connecting…, mantén pulsado el botón PRG. - En la pantalla verás primero «Buscando satelites…». A los 10 segundos, si estás dentro de casa, pasará a «SIMULADA (sin senal)» con una latitud y una longitud.
Para que la vuelta simulada quede cerca de ti, cambia LAT0 y LON0 por las coordenadas de tu casa o de tu clase (puedes verlas en cualquier aplicación de mapas).
Paso 5. Sube el programa de la base
- Desconecta la araña y conecta la otra placa, la BASE.
- Archivo → Nuevo sketch, borra todo y pega el programa de la base (sección «Código 2»). Guárdalo como
chisme28_base. - Elige la placa ESP32 Dev Module y el puerto de la base. Cambia
BANDAsi hace falta. Debe ser la misma que en la araña. - Pulsa Subir.
- En la pantalla verás «Esperando a la arana». Conecta otra vez la araña (con USB o batería) y, en menos de 10 segundos, la base mostrará
** SIMULADA **, la latitud, la longitud, los satélites, la señal en dBm y cuánto hace del último dato.
Mostrar imagen
Si la base recibe los datos de la araña, LoRa funciona y tu rastreador GPS ya se habla por radio. Es una gran noticia: lo difícil ya está hecho.
Paso 6. Abre la página en el móvil
Tienes dos opciones:
- Usar la nuestra: abre https://montesteam.org/app/chisme-28-lince-seguro.html
- Tener la tuya: copia el código de la página (sección «Código 3»), guárdalo como
chisme-28-lince-seguro.htmly súbelo a un sitio con https. Conhttp://o desde un archivo local, el Bluetooth y la ubicación del navegador no funcionan.
Paso 7. Conecta y mira la posición
- Con el rastreador GPS encendido, abre en el móvil (con Bluetooth y ubicación activados) la página en Chrome.
- Pulsa Conectar la base y elige Heltec-Lince.
- Tras los mensajes de «Conectando…» y «Activando notificaciones…» verás «Conectado a Heltec-Lince. Esperando a la araña…».
- En menos de 10 segundos aparecen la latitud, la longitud, los satélites y la señal.
- Fíjate en la etiqueta de colores sobre las coordenadas: naranja, «🧪 POSICIÓN SIMULADA», si el GPS no tiene señal, y verde, «🛰️ POSICIÓN REAL (GPS)», si la posición viene de los satélites.
- Espera un minuto: la latitud y la longitud van cambiando, porque la araña simulada da una vuelta.
Paso 8. Activa el radar
- Pulsa Activar mi posición y permite la ubicación (y la brújula, si el móvil la pide).
- Aparecen la distancia a la araña y la flecha. La flecha apunta hacia ella.
- Gira el móvil hasta que la flecha mire hacia arriba: la araña está justo delante.
Mostrar imagen
Si ves una distancia de muchos kilómetros, es porque la vuelta simulada empieza en las coordenadas de LAT0 y LON0, y tú estás lejos de ellas. Cámbialas por las de tu zona (paso 4).
Paso 9. Sal a la calle: el GPS de verdad
Dentro de casa o de clase, el GPS casi nunca recibe señal. Para probar el rastreador GPS de verdad, sal a un balcón, una terraza o el patio, con cielo abierto.
- Deja la araña con su GPS en un sitio despejado, con el módulo mirando al cielo, sin nada metálico encima.
- Espera. La primera señal, el llamado arranque en frío, puede tardar de medio minuto a varios minutos. En muchos módulos hay un LED que parpadea cuando encuentra la posición.
- En cuanto la tenga, la araña pasa a real sola: lo verás en su pantalla y en la etiqueta verde de la página.
- Lleva la araña de paseo en una mochila (con una batería externa) y aléjate con el móvil. Mira cómo cambia la distancia y hacia dónde apunta la flecha.
LoRa tiene mucho alcance. Con visión directa, en un patio o en el campo, puede llegar a cientos de metros o más. Entre edificios y paredes, mucho menos.


Cómo funciona el rastreador GPS
El GPS habla en frases
El módulo GPS recibe señales de satélites y calcula dónde está. Cada segundo manda por su cable TX unas frases de texto con la latitud, la longitud, la hora y los satélites que ve. Es una especie de «idioma del GPS». La librería TinyGPSPlus lo traduce a números, y nosotros solo preguntamos gps.location.lat() y gps.location.lng(). La placa recibe esas frases por un puerto serie (Serial2) a 9600 baudios, la velocidad habitual del NEO-6M.
Si no hay señal, la araña simula
El rastreador GPS necesita cielo abierto para funcionar de verdad, así que en clase usamos un truco. En cada envío, la araña mira si el GPS tiene una posición válida y reciente (gps.location.isValid() y age() < 5000):
- Si la tiene: manda la posición real y la marca con una R.
- Si no la tiene: calcula una posición inventada que da una vuelta de 80 metros de radio en 60 segundos (
RADIO_MyVUELTA_MS), y la marca con una S.
Como el GPS se vuelve a mirar en cada envío, la araña pasa de simulada a real en cuanto hay señal, sin tener que esperar a que acabe la vuelta. Y fíjate en un detalle: aunque simule, los satélites que manda son los que de verdad ve el GPS (casi siempre cero dentro de casa). Así nunca nos engañamos: lo simulado se ve como simulado.
LoRa: pocos datos, mucha distancia
LoRa es una radio pensada para mandar mensajes pequeños a mucha distancia gastando muy poca batería. No sirve para música ni para vídeo, pero es perfecta para una posición. Nuestro mensaje es una sola línea de texto:
36.42800,-5.14500,0,S
Son cuatro datos: latitud, longitud, satélites y modo (R o S). Cuatro detalles del programa:
setSyncWord(0x42): una especie de contraseña corta. La base solo escucha mensajes con esa contraseña, y no se mezcla con otras redes LoRa de alrededor.setTxPower(14): potencia de 14 dBm, unos 25 mW, el máximo habitual en la banda de 868 MHz.- Un envío cada 10 segundos (
PERIODO): en 868 MHz se suele limitar el tiempo de emisión a un pequeño porcentaje, así que no conviene mandar más a menudo. - La antena siempre puesta.
La base reenvía y añade la señal
Cuando la base recibe un mensaje, mide también cuánta señal le llega (LoRa.packetRssi(), en dBm; cuanto más cerca de cero, mejor) y lo reenvía por Bluetooth, con una P: delante y la señal al final:
P:36.42800,-5.14500,0,S,-92
Este mensaje no cabe en un solo aviso de Bluetooth (el límite es de 20 bytes), así que la base lo manda en trozos. La página ya sabe juntarlos: va acumulando letras hasta que llega el salto de línea. Es el camino inverso al del Chisme 25, donde troceaba la página.
La página calcula la distancia y la flecha
La página tiene dos posiciones: la de la araña (que llega por Bluetooth) y la del móvil (que da el propio móvil con la API de ubicación, Geolocation API). Con ellas hace dos cálculos:
- Distancia: la fórmula de haversine, que calcula la distancia entre dos puntos de una esfera, porque la Tierra es redonda y no vale el teorema de Pitágoras.
- Rumbo: el ángulo, desde el norte, en el que está la araña.
La flecha gira rumbo menos la dirección hacia la que mira el móvil. Esa dirección la da la brújula del móvil. Si tu móvil no tiene brújula, la página avisa de que la flecha apunta respecto al norte.
Los números de este rastreador GPS
| Dato | Valor aproximado |
|---|---|
| Precisión del GPS a cielo abierto | de unos pocos metros |
| Primera señal (arranque en frío) | de 30 segundos a varios minutos |
| Envío de la posición | cada 10 segundos |
| Vuelta simulada | 80 m de radio, 60 segundos |
El GPS no es exacto
Cuando la araña está cerca de ti, no te fíes ciegamente de la flecha. El GPS del NEO-6M y el de tu móvil se equivocan unos metros, y entre edificios o árboles, bastante más. A diez metros de la araña, las dos posiciones pueden bailar y la flecha girar sin sentido. A esa distancia, fíate de tus ojos.
Retos para clase con el rastreador GPS
- Tu patio: pon en
LAT0yLON0las coordenadas de tu clase y comprueba que el radar marca la distancia correcta a la vuelta simulada. - Más lento, más rápido: cambia
VUELTA_MSyRADIO_M. ¿Cómo cambia la latitud en la pantalla? - Más a menudo: cambia
PERIODOa 5000. ¿Qué pasa con la batería? ¿Y con el límite de uso de la radio? - El alcance: sal con la araña y aléjate de la base hasta que se pierdan los datos. Apunta la distancia. ¿Cambia con paredes en medio?
- Lince fuera de zona: haz que la página avise (y vibre el móvil) cuando la araña esté a más de 100 metros de ti. Es lo que haría un rastreador GPS de protección de verdad.
- Las reglas del Lince de la Noche: escribid en clase una lista de cuándo sí y cuándo no se puede usar un rastreador. ¿Un perro? ¿Una bicicleta? ¿Un compañero que dice que sí? ¿Un compañero que no lo sabe?
- Reto avanzado: guarda las posiciones y dibuja el recorrido en una hoja de cálculo. El mapa lo dejamos para otro Chisme.
Si tu rastreador GPS falla
- «No arranca la radio LoRa» en el Monitor serie: la placa no encuentra la radio. Comprueba que has elegido ESP32 Dev Module y que tu placa usa los pines
5, 19, 27, 18, 14, 26(los de la TTGO LoRa32 v1.x). - La base no recibe nada: comprueba que las dos placas usan la misma banda (
BANDA), que las dos tienen la antena puesta y que has subido cada programa a la placa correcta. Prueba con las placas a un par de metros de distancia, pero no pegadas. - La araña siempre dice «SIMULADA»: el GPS no tiene señal. Sal al exterior y espera varios minutos la primera vez. Si sigue igual, revisa el cableado: TX del GPS al pin 13, y 3V3 y GND.
- Los satélites siempre son 0 en el exterior: puede que el TX y el RX estén cruzados, o que el módulo esté mal alimentado. Mira el Monitor serie de la araña a 115200 baudios.
- La página se conecta pero no salen datos: comprueba que has subido el programa de la base a la placa que conectas, y que la araña está encendida. La base tarda hasta 10 segundos en recibir.
- El radar marca muchos kilómetros: la vuelta simulada empieza en
LAT0,LON0. Cámbialas por las de tu zona. - No sale el radar: pulsa Activar mi posición y permite la ubicación.
- La flecha no gira con el móvil: tu móvil no da la brújula, o no has dado el permiso. La flecha apunta entonces respecto al norte.
- 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 rastreador GPS?
Es un aparato que averigua dónde está gracias a los satélites del GPS y manda esa posición a otro lugar. En este Chisme, un módulo GPS y una placa Heltec forman el rastreador, y otra Heltec con el móvil lo reciben.
¿Funciona el GPS dentro de casa?
Casi nunca. Las señales de los satélites son muy débiles y las paredes y el techo las tapan. Por eso la araña simula una vuelta cuando no tiene señal, y por eso hay que salir al exterior para probar el GPS de verdad.
¿Hace falta internet o datos en el móvil?
Solo para abrir la página, que está en montesteam.org. Ábrela antes de salir y déjala abierta: una vez cargada, funciona sin datos, porque la posición de la araña llega por radio y por Bluetooth, el GPS del móvil no necesita internet y el radar no usa mapa.
¿Puedo usar otro módulo GPS?
Sí. Otros módulos de la familia NEO, como el NEO-M8N, o el ATGM336H, funcionan igual y casi todos hablan a 9600 baudios. Con otro módulo, el rastreador GPS se monta igual: cambia solo el pin de conexión y la velocidad en Serial2.begin si hace falta.
¿Es legal poner un rastreador GPS a alguien?
A una persona, no sin su permiso: vulnera su intimidad y su protección de datos, y puede ser delito. A objetos tuyos, sí. A tu mascota, sí. A animales silvestres, y más a una especie protegida como el lince ibérico, solo pueden hacerlo equipos autorizados. En clase lo practicamos con un peluche o una mochila.
Qué hemos aprendido
Con este rastreador GPS hemos conectado un GPS a una placa y hemos aprendido que entrega frases de texto que una librería traduce a números. Hemos usado LoRa para que dos placas se hablen por radio sin wifi ni internet, y hemos visto que es una radio de mensajes pequeños y mucha distancia. Hemos aprendido a simular datos cuando no hay señal, diciendo siempre que son simulados, y a calcular la distancia y el rumbo entre dos posiciones de la Tierra. Y, sobre todo, hemos aprendido la norma del Lince de la Noche: la tecnología se usa para proteger, y siempre con permiso.
Más adelante, este rastreador GPS podrá dibujar su recorrido en un mapa. ¿Quieres seguir con la Heltec? Mira el curso gratuito de micro:bit de MonteSTEAM y el resto de los Chismes del Lince.
Código 1: programa de la araña (Arduino IDE)
/*
Chisme 28 · Lince Seguro · LA ARAÑA (placa A)
Lee el GPS NEO-6M, enseña la posición en la pantalla OLED y la manda por LoRa
a la placa base cada 10 segundos.
SI EL GPS NO TIENE SEÑAL (dentro de casa o de clase), la araña inventa una
vuelta grande que dura 1 minuto y lo avisa como "S" (simulada). En cada envío
vuelve a mirar el GPS: en cuanto tiene señal, pasa a "R" (real) sola.
Librerías (Gestor de bibliotecas): "TinyGPSPlus" (Mikal Hart), "LoRa" (Sandeep Mistry),
Adafruit SSD1306 y Adafruit GFX.
Placa en el Arduino IDE: "ESP32 Dev Module".
CABLEADO DEL GPS NEO-6M (con la placa apagada):
VCC -> 3V3 GND -> GND
TX del GPS -> GPIO 13 (el RX del GPS no hace falta)
¡PON LA ANTENA LORA ANTES DE ENCENDER LA PLACA!
Mensaje por LoRa: latitud,longitud,satélites,modo (modo: R real, S simulada)
*/
#include <SPI.h>
#include <LoRa.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <TinyGPSPlus.h>
#define SIMULAR_SIN_SENAL 1 // 1 = sin señal GPS, inventa una vuelta; 0 = sin señal, no manda nada
#define BANDA 868E6 // mira la etiqueta de tu placa: 433E6, 868E6 o 915E6
#define PERIODO 10000 // ms entre envíos
#define LAT0 36.4280 // dónde empieza la vuelta simulada: cámbialo por tu zona
#define LON0 -5.1450
#define RADIO_M 80 // radio de la vuelta simulada, en metros
#define VUELTA_MS 60000 // lo que tarda en dar la vuelta: 1 minuto
#define OLED_SDA 21
#define OLED_SCL 22
#define OLED_RST 16
#define OLED_ADDR 0x3C
#define GPS_RX 13
#define LORA_SCK 5
#define LORA_MISO 19
#define LORA_MOSI 27
#define LORA_SS 18
#define LORA_RST 14
#define LORA_DIO0 26
Adafruit_SSD1306 display(128, 64, &Wire, OLED_RST);
TinyGPSPlus gps;
unsigned long ultimo = 0, inicioSim = 0;
bool simulando = false;
void pintar(bool ok, double lat, double lon, int sat, char modo, bool enviado) {
display.clearDisplay();
display.setTextSize(1);
display.setCursor(0, 0);
display.print(!ok ? "LA ARANA" : modo == 'S' ? "SIMULADA (sin senal)" : "GPS REAL");
display.setCursor(0, 14);
if (ok) {
display.print("Lat: "); display.println(lat, 5);
display.print("Lon: "); display.println(lon, 5);
} else {
display.println("Buscando satelites...");
}
display.setCursor(0, 44);
display.print("Satelites: "); display.println(sat);
display.print(enviado ? "LoRa: enviado" : "LoRa: esperando");
display.display();
}
void setup() {
Serial.begin(115200);
Serial2.begin(9600, SERIAL_8N1, GPS_RX, -1);
Wire.begin(OLED_SDA, OLED_SCL);
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(false, 0, 0, 0, 'R', false);
SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_SS);
LoRa.setPins(LORA_SS, LORA_RST, LORA_DIO0);
if (!LoRa.begin(BANDA)) { Serial.println("No arranca la radio LoRa"); while (true) delay(1000); }
LoRa.setSyncWord(0x42); // para no mezclarnos con otras redes
LoRa.setTxPower(14); // 14 dBm = 25 mW, el máximo habitual en 868 MHz
}
void loop() {
while (Serial2.available()) gps.encode(Serial2.read()); // el GPS habla sin parar
if (millis() - ultimo >= PERIODO) {
ultimo = millis();
int sat = gps.satellites.isValid() ? gps.satellites.value() : 0;
bool real = gps.location.isValid() && gps.location.age() < 5000; // se vuelve a probar el GPS en cada envío
bool ok = false;
double lat = 0, lon = 0;
char modo = 'R';
if (real) {
lat = gps.location.lat();
lon = gps.location.lng();
ok = true;
simulando = false;
} else if (SIMULAR_SIN_SENAL) {
if (!simulando) { simulando = true; inicioSim = millis(); } // la vuelta empieza en LAT0, LON0
float a = 2 * PI * ((millis() - inicioSim) % VUELTA_MS) / VUELTA_MS;
lat = LAT0 + (RADIO_M / 111320.0) * sin(a);
lon = LON0 + (RADIO_M / (111320.0 * cos(LAT0 * PI / 180.0))) * (1 - cos(a));
ok = true;
modo = 'S';
}
if (ok) {
String m = String(lat, 5) + "," + String(lon, 5) + "," + String(sat) + "," + String(modo);
LoRa.beginPacket();
LoRa.print(m);
LoRa.endPacket();
Serial.println("Enviado: " + m);
} else {
Serial.println("Sin posicion todavia. Satelites: " + String(sat));
}
pintar(ok, lat, lon, sat, modo, ok);
}
}
Código 2: programa de la base (Arduino IDE)
/*
Chisme 28 · Lince Seguro · LA BASE (placa B)
Recibe por LoRa la posición de la araña, la enseña en su pantalla OLED
y la reenvía por Bluetooth a la página del móvil.
Librerías: "LoRa" (Sandeep Mistry), Adafruit SSD1306 y Adafruit GFX.
Placa en el Arduino IDE: "ESP32 Dev Module".
¡PON LA ANTENA LORA ANTES DE ENCENDER LA PLACA!
Mensaje por Bluetooth (una línea): P:36.42800,-5.14500,8,S,-92
(latitud, longitud, satélites, modo R real o S simulada, señal en dBm)
*/
#include <SPI.h>
#include <LoRa.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#define BANDA 868E6 // la misma que la de la araña
#define OLED_SDA 21
#define OLED_SCL 22
#define OLED_RST 16
#define OLED_ADDR 0x3C
#define LORA_SCK 5
#define LORA_MISO 19
#define LORA_MOSI 27
#define LORA_SS 18
#define LORA_RST 14
#define LORA_DIO0 26
#define SERVICE_UUID "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
#define UUID_TX "6e400003-b5a3-f393-e0a9-e50e24dcca9e"
Adafruit_SSD1306 display(128, 64, &Wire, OLED_RST);
BLECharacteristic *tx = nullptr;
volatile bool conectado = false;
String datos = "";
int rssi = 0;
unsigned long ultimoRx = 0, ultimoRefresco = 0;
class AlConectar : public BLEServerCallbacks {
void onConnect(BLEServer *s) { conectado = true; }
void onDisconnect(BLEServer *s) { conectado = false; BLEDevice::startAdvertising(); }
};
void enviar(const String &m) { // trozos de 20 bytes: la página los junta
if (!conectado || !tx) return;
String s = m + "\n";
for (size_t i = 0; i < s.length(); i += 20) {
String t = s.substring(i, i + 20);
tx->setValue((uint8_t *)t.c_str(), t.length());
tx->notify();
delay(10);
}
}
void pintar() {
display.clearDisplay();
display.setTextSize(1);
display.setCursor(0, 0);
display.print(conectado ? "BASE BT: conectado" : "BASE BT: esperando");
display.setCursor(0, 14);
if (datos == "") {
display.print("Esperando a la arana");
} else {
int c1 = datos.indexOf(','), c2 = datos.indexOf(',', c1 + 1), c3 = datos.indexOf(',', c2 + 1);
display.println(datos.substring(c3 + 1) == "S" ? "** SIMULADA **" : "** GPS REAL **");
display.print("Lat: "); display.println(datos.substring(0, c1));
display.print("Lon: "); display.println(datos.substring(c1 + 1, c2));
display.print("Sat: "); display.println(datos.substring(c2 + 1, c3));
display.print("Senal: "); display.print(rssi); display.println(" dBm");
display.print("Hace "); display.print((millis() - ultimoRx) / 1000); display.print(" s");
}
display.display();
}
void setup() {
Serial.begin(115200);
Wire.begin(OLED_SDA, OLED_SCL);
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();
SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_SS);
LoRa.setPins(LORA_SS, LORA_RST, LORA_DIO0);
if (!LoRa.begin(BANDA)) { Serial.println("No arranca la radio LoRa"); while (true) delay(1000); }
LoRa.setSyncWord(0x42);
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());
svc->start();
BLEAdvertising *pub = BLEDevice::getAdvertising();
pub->addServiceUUID(SERVICE_UUID);
pub->setScanResponse(true);
BLEDevice::startAdvertising();
}
void loop() {
if (LoRa.parsePacket() > 0) {
String m = "";
while (LoRa.available()) m += (char)LoRa.read();
if (m.indexOf(',') > 0 && m.lastIndexOf(',') > m.indexOf(',')) { // parece una posición
datos = m;
rssi = LoRa.packetRssi();
ultimoRx = millis();
Serial.println("Recibido: " + m + " RSSI " + String(rssi));
enviar("P:" + m + "," + String(rssi));
pintar();
}
}
if (millis() - ultimoRefresco > 1000) { // refresca "Hace X s" y el estado del Bluetooth
ultimoRefresco = millis();
pintar();
}
}
Código 3: 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 28 · Lince Seguro 🐾 (Heltec + GPS)</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:disabled{opacity:.5;cursor:not-allowed}
button:focus-visible{outline:3px solid var(--slow);outline-offset:2px}
#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}
.tarjeta{background:var(--card);border:1px solid var(--line);border-radius:1rem;padding:.8rem 1rem;margin:.8rem 0}
.tarjeta h2{margin:0 0 .3rem;font-size:1rem;color:var(--soft)}
.modo{display:inline-block;font-weight:700;border-radius:2rem;padding:.15rem .9rem;margin-bottom:.4rem;background:var(--line);color:var(--ink)}
.modo.real{background:var(--ok);color:var(--bg)}
.modo.sim{background:var(--oro);color:#1d1400}
.coord{font:700 1.5rem/1.3 ui-monospace,Menlo,Consolas,monospace;color:var(--pine)}
.datos{color:var(--soft);font-size:.9rem;min-height:1.3em}
#radar{width:min(80vw,15rem);display:block;margin:.4rem auto}
.aro{fill:none;stroke:var(--line);stroke-width:1.5}
#flecha polygon{fill:var(--norte)}
.frente{fill:var(--pine)}
#dist{font-size:2.6rem;font-weight:700;line-height:1.1}
#rumbo{color:var(--soft);font-size:.95rem;min-height:1.3em}
.nota{color:var(--soft);font-size:.85rem;margin:.6rem 0 0}
.ficcion{margin-top:1.4rem;font-style:italic;color:var(--soft)}
.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>Chisme 28 — Lince Seguro 🐾</h1>
<p class="sub">GPS para localizar y proteger al lince ibérico.</p>
<button id="conectar">Conectar la base</button>
<div id="estado" role="status">Sin conectar.</div>
<div id="ayuda"></div>
<section class="tarjeta" aria-label="Posición de la araña">
<h2>Posición de la araña</h2>
<div class="modo" id="modo">Esperando datos…</div>
<div class="coord" id="lat">Lat: --</div>
<div class="coord" id="lon">Lon: --</div>
<div class="datos" id="datos">Satélites: -- · Señal: --</div>
<div class="datos" id="edad" aria-live="polite"></div>
</section>
<section class="tarjeta" aria-label="Radar">
<h2>Radar: ¿dónde está la araña?</h2>
<svg id="radar" viewBox="0 0 200 200" aria-hidden="true">
<circle class="aro" cx="100" cy="100" r="92"/><circle class="aro" cx="100" cy="100" r="60"/><circle class="aro" cx="100" cy="100" r="28"/>
<polygon class="frente" points="100,2 94,14 106,14"/>
<g id="flecha"><polygon points="100,22 120,100 100,88 80,100"/></g>
</svg>
<div id="dist">--</div>
<div id="rumbo">Pulsa «Activar mi posición» para ver el radar.</div>
<button id="activar" style="margin-top:.6rem">Activar mi posición</button>
<div class="datos" id="yo"></div>
<p class="nota">La flecha apunta hacia la araña. Gira el móvil hasta que la flecha mire hacia arriba y camina en esa dirección. Con GPS la precisión es de unos metros: cerca de la araña, fíate de tus ojos.</p>
</section>
<p class="ficcion">Primero protegemos al lince. Lince de la Noche tiene una norma: nunca rastrea a nadie sin su permiso. 😼</p>
<details class="guia">
<summary>Guía de uso</summary>
<ol>
<li>Carga <b>chisme-28-lince-seguro-arana.ino</b> en una Heltec (la araña, con el GPS) y <b>chisme-28-lince-seguro-base.ino</b> en la otra (la base). Pon las antenas antes de encenderlas.</li>
<li>Abre esta página con <b>Chrome</b> y pulsa <b>Conectar la base</b>. Elige <b>Heltec-Lince</b>.</li>
<li>Cada 10 segundos verás las coordenadas de la araña. Si su GPS no tiene señal, inventa una vuelta de un minuto y la página lo marca como <b>simulada</b>; en cuanto el GPS coge señal, pasa a <b>real</b> sola.</li>
<li>Pulsa <b>Activar mi posición</b>, permite la ubicación y la brújula, y sigue la flecha.</li>
</ol>
</details>
</main>
<script>
const UART_SERVICE = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
const UART_TX = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"; // la base envía por aquí
const $ = id => document.getElementById(id);
const espera = ms => new Promise(r => setTimeout(r, ms));
let dispositivo = null, conectando = false, reconectando = false, buffer = "", pasoCx = "", diag = "";
let arana = null, yo = null, rumbo = null, ultimoDato = 0;
/* Matemáticas del radar */
const rad = d => d * Math.PI / 180, deg = r => r * 180 / Math.PI;
function distancia(a, b){ // metros entre dos puntos (haversine)
const dLat = rad(b.lat - a.lat), dLon = rad(b.lon - a.lon);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2;
return 2 * 6371000 * Math.asin(Math.sqrt(h));
}
function rumboHacia(a, b){ // grados desde el norte (0 a 360)
const y = Math.sin(rad(b.lon - a.lon)) * Math.cos(rad(b.lat));
const x = Math.cos(rad(a.lat)) * Math.sin(rad(b.lat)) - Math.sin(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.cos(rad(b.lon - a.lon));
return (deg(Math.atan2(y, x)) + 360) % 360;
}
const PUNTOS = ["norte", "noreste", "este", "sureste", "sur", "suroeste", "oeste", "noroeste"];
function radar(){
if (!arana || !yo) return;
const d = distancia(yo, arana), b = rumboHacia(yo, arana);
$("dist").textContent = d < 1000 ? Math.round(d) + " m" : (d / 1000).toFixed(2) + " km";
const giro = b - (rumbo === null ? 0 : rumbo);
$("flecha").setAttribute("transform", "rotate(" + giro.toFixed(1) + " 100 100)");
$("rumbo").textContent = "Está hacia el " + PUNTOS[Math.round(b / 45) % 8] + (rumbo === null ? " (sin brújula: la flecha apunta respecto al norte)" : "");
}
$("activar").onclick = async () => {
if (!navigator.geolocation){ $("yo").textContent = "Este navegador no da la ubicación."; return; }
navigator.geolocation.watchPosition(p => {
yo = { lat: p.coords.latitude, lon: p.coords.longitude };
$("yo").textContent = "Tu posición: precisión de unos " + Math.round(p.coords.accuracy) + " m";
radar();
}, e => { $("yo").textContent = "No se pudo obtener tu posición (" + e.message + "). Permite la ubicación."; },
{ enableHighAccuracy: true, maximumAge: 2000 });
try {
if (typeof DeviceOrientationEvent !== "undefined" && DeviceOrientationEvent.requestPermission)
await DeviceOrientationEvent.requestPermission();
} catch (_){}
addEventListener("deviceorientationabsolute", o => { if (o.alpha !== null){ rumbo = (360 - o.alpha) % 360; radar(); } }, true);
addEventListener("deviceorientation", o => { if (o.webkitCompassHeading != null){ rumbo = o.webkitCompassHeading; radar(); } });
};
/* Lo que manda la base: «P:latitud,longitud,satélites,modo,señal» */
function linea(t){
const m = /^P:(-?\d+\.\d+),(-?\d+\.\d+),(\d+),([RS]),(-?\d+)$/.exec(t);
if (!m) return;
arana = { lat: parseFloat(m[1]), lon: parseFloat(m[2]) };
ultimoDato = Date.now();
$("lat").textContent = "Lat: " + m[1];
$("lon").textContent = "Lon: " + m[2];
$("datos").textContent = "Satélites: " + m[3] + " · Señal: " + m[5] + " dBm";
const sim = m[4] === "S";
$("modo").textContent = sim ? "🧪 POSICIÓN SIMULADA (el GPS no tiene señal)" : "🛰️ POSICIÓN REAL (GPS)";
$("modo").className = "modo " + (sim ? "sim" : "real");
radar();
}
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); }
}
setInterval(() => {
if (!ultimoDato) return;
const s = Math.round((Date.now() - ultimoDato) / 1000);
$("edad").textContent = s < 3 ? "" : "Último dato hace " + s + " s" + (s > 30 ? ": ¿la araña sigue encendida y con cielo abierto?" : "");
}, 1000);
/* Conexión Bluetooth (mismo esquema que las páginas 21 a 25) */
const propsDe = c => ["read", "write", "writeWithoutResponse", "notify", "indicate"].filter(k => c.properties[k]).join(", ");
function estado(t, ayuda){ if (!/^Error/.test(t)) pasoCx = t; $("estado").textContent = t; $("ayuda").textContent = ayuda || ""; }
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 28.";
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;
estado("Conectado a " + (dev.name || "Heltec") + ". Esperando a la araña…");
}
async function reconectar(){
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>

