Chismes del Lince 20: El nivel de burbuja
Con el cuenta pasos aprendimos a mandar datos de la micro:bit al móvil. Ahora usamos esa misma tubería para algo muy útil en la Lince Cueva: un nivel de burbuja. La micro:bit mide su inclinación, se la manda al móvil por Bluetooth y el móvil dibuja una burbuja que se mueve igual que la de un nivel de albañil. Cuando la superficie está recta, la burbuja se pone verde, el móvil vibra y aparece ¡Nivelado!
Sirve para colgar una balda sin que se tuerza, montar una mesa de trabajo, comprobar que una rampa está bien hecha o plantar un poste bien vertical.
Lo que necesitas
- Un micro:bit V2 con su portapilas.
- Un móvil Android con Chrome, con el Bluetooth y la ubicación encendidos. En iPhone, Safari no sirve: hay que abrir la página en el navegador Bluefy.
- El ordenador con MakeCode para programar.
Paso 1. Programa el micro:bit
Crea un proyecto nuevo en MakeCode y usa solo bloques.
- Añade la extensión Bluetooth (Extensiones → Bluetooth). No añadas la de Radio.
- Monta este programa, que es muy pequeño:
al iniciar
bluetooth servicio uart
para siempre
uart bluetooth escribir líneaconunir(rotación (°) roll)","(rotación (°) pitch)pausa 100 ms
Los bloques de rotación están en Entrada → Más. El micro:bit manda dos números separados por una coma, por ejemplo -3,5: la inclinación a izquierda/derecha (roll) y la de delante/detrás (pitch), en grados.
Si prefieres JavaScript, cambia al modo JavaScript y pega esto:
bluetooth.startUartService()
basic.forever(function () {
bluetooth.uartWriteLine("" + input.rotation(Rotation.Roll) + "," + input.rotation(Rotation.Pitch))
basic.pause(100)
})

Paso 2. Sin emparejamiento (muy importante)
Antes de descargar, hay que cambiar un ajuste para que la página pueda conectar sin pedir códigos:
- Pulsa el engranaje ⚙️ → Configuración del proyecto.
- Marca No se requiere emparejamiento (No Pairing Required: Anyone can connect via Bluetooth).
Paso 3. Pásalo al micro:bit
- Descarga el programa y cópialo a la unidad MICROBIT.
- Espera a que deje de parpadear el led amarillo de atrás.
- Reinicia el micro:bit con el botón de atrás.
Si cambias algo del programa, repite los pasos 2 y 3.
Paso 4. Prepara el móvil
- Bluetooth y ubicación encendidos.
- Si en Ajustes → Bluetooth aparece “BBC micro:bit”, pulsa Olvidar. No hay que emparejarla desde ahí.
- Cierra cualquier otra app que use el micro:bit.
Paso 5. Abre la página y conecta
- Abre en Chrome, escribiendo la dirección directamente: https://montesteam.org/app/chisme-20-nivel-burbuja-microbit.html
- Pulsa Conectar micro:bit.
- Elige BBC micro:bit […] en la lista.
- Cuando la página ponga Conectado, apoya el micro:bit sobre la superficie que quieres medir, con la matriz de LEDs hacia arriba.
Paso 6. Mide
La burbuja se mueve hacia el lado más alto, igual que en un nivel de verdad. La página te enseña:
- Izquierda–derecha y Delante–detrás: los grados de inclinación en cada dirección.
- Inclinación total: cuánto se aparta la superficie de estar plana.
- El aviso: “Inclinado”, “Casi…” o “¡Nivelado!” (cuando la inclinación es de 1° o menos). En ese momento el círculo se pone verde y el móvil vibra.
- Calibrar a cero: si apoyas el micro:bit en una superficie que sabes que está recta y la burbuja no queda en el centro, púlsalo y esa posición pasa a ser el cero.
- Invertir: si la burbuja se mueve al revés de lo que esperas en alguna dirección, usa los botones de invertir.
Retos para clase
- ¿Está recta la mesa de clase? ¿Y el suelo? ¿Y el alféizar de la ventana?
- Apoya el micro:bit en una rampa y mide cuántos grados tiene. ¿Cuál es la más inclinada por la que puede subir un coche de juguete sin que se deslice?
- Haz una tabla con las inclinaciones de cinco superficies de la Lince Cueva, de la más recta a la más torcida.
- Mete el micro:bit en una cajita de cartón y apóyala por distintos lados. ¿Cambia la medida?
- Reto avanzado: que el micro:bit enseñe en su matriz de LEDs una flecha que indique hacia qué lado hay que subir el borde para nivelar.
Si algo falla
- No aparece en la lista: comprueba que está encendido, que el programa está descargado y que activaste «No se requiere emparejamiento».
- Se conecta pero la burbuja no se mueve: comprueba que el programa tiene el bloque
bluetooth servicio uartal iniciar y que manda los dos números separados por una coma. - La burbuja nunca queda en el centro: pulsa Calibrar a cero con el micro:bit sobre una superficie que sepas que está recta.
- En iPhone no conecta: abre la página con Bluefy.

Qué hemos aprendido
El acelerómetro del micro:bit no solo detecta golpes: también nota hacia dónde tira la gravedad, y con eso se calcula la inclinación. Una coma separa dos medidas en la misma línea, y el móvil las lee y las convierte en un dibujo. Y un cero de referencia (la calibración) permite medir desde cualquier punto de partida.
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 20 · El nivel de burbuja del Lince</title>
<style>
:root{
--bg:#eef2ef; --ink:#10201c; --soft:#4d625b; --card:#ffffff; --line:#cfdad5;
--pine:#1f5b4a; --slow:#2563a8; --ok:#1f7a4d;
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;}}
:root[data-theme="dark"]{--bg:#0f1a17; --ink:#e8f1ed; --soft:#9db3aa; --card:#172622; --line:#2a3d37;
--pine:#5fc4a2; --slow:#7db3ee; --ok:#6fd89f;}
*{box-sizing:border-box}
html{scroll-padding-top:env(safe-area-inset-top,0px)}
body{margin:0;background:var(--bg);color:var(--ink);font:17px/1.45 "Trebuchet MS","Segoe UI",system-ui,sans-serif}
main{max-width:30rem;margin:0 auto;padding:1.2rem 1rem 2rem}
h1{font-size:1.3rem;margin:.2rem 0 .1rem}
.sub{color:var(--soft);margin:0 0 1rem;font-size:.95rem}
button{font:inherit;font-weight:700;border:2px solid var(--pine);background:var(--pine);color:var(--bg);
border-radius:.8rem;padding:.75rem 1.1rem;min-height:3rem;cursor:pointer}
button:disabled{opacity:.55;cursor:wait}
button.alt{background:transparent;color:var(--pine)}
button:focus-visible{outline:3px solid var(--slow);outline-offset:2px}
.botones{display:flex;gap:.6rem;flex-wrap:wrap}
#estado{margin:.8rem 0 .2rem;color:var(--soft);min-height:1.5em}
#ayuda{margin:0 0 .6rem;color:var(--soft);font-size:.9rem;min-height:1.2em}
.vial{position:relative;width:min(78vw,18rem);aspect-ratio:1;margin:1rem auto;border-radius:50%;
border:.5rem solid var(--line);background:var(--card);overflow:hidden;transition:border-color .2s}
.vial.ok{border-color:var(--ok)}
.vial::before,.vial::after{content:"";position:absolute;background:var(--line)}
.vial::before{left:0;right:0;top:50%;height:2px}
.vial::after{top:0;bottom:0;left:50%;width:2px}
.diana{position:absolute;left:50%;top:50%;width:24%;aspect-ratio:1;transform:translate(-50%,-50%);
border:3px solid var(--soft);border-radius:50%}
.vial.ok .diana{border-color:var(--ok)}
.burbuja{position:absolute;left:50%;top:50%;width:18%;aspect-ratio:1;margin:-9% 0 0 -9%;border-radius:50%;
background:var(--pine);opacity:.88;z-index:1;transition:transform .12s linear,background .2s}
.vial.ok .burbuja{background:var(--ok)}
@media (prefers-reduced-motion:reduce){.burbuja{transition:none}}
#aviso{font-weight:700;text-align:center;min-height:1.6em;margin:.4rem 0 1rem;font-size:1.15rem}
.lbl{color:var(--soft);font-size:.95rem}
.fila{display:flex;gap:.8rem;margin:1rem 0}
.dato{flex:1;background:var(--card);border:1px solid var(--line);border-radius:.8rem;padding:.7rem .9rem}
.dato b{display:block;font-size:2rem;font-variant-numeric:tabular-nums}
.pie{display:flex;gap:.6rem;flex-wrap:wrap}
.pie button{min-height:2.6rem;padding:.5rem .9rem;font-size:.9rem}
</style>
</head>
<body>
<main>
<h1>El nivel de burbuja del Lince</h1>
<p class="sub">Micro:bit apoyada en la superficie, móvil para mirar la burbuja.</p>
<div class="botones">
<button id="conectar">Conectar micro:bit</button>
<button id="cero" class="alt">Calibrar a cero</button>
</div>
<div id="estado" role="status">Sin conectar.</div>
<div id="ayuda"></div>
<div class="vial" id="vial"><div class="diana"></div><div class="burbuja" id="burbuja"></div></div>
<div id="aviso" aria-live="polite"></div>
<div class="fila">
<div class="dato"><span class="lbl">Izquierda–derecha</span><b id="ax">0°</b></div>
<div class="dato"><span class="lbl">Delante–detrás</span><b id="ay">0°</b></div>
</div>
<div class="dato"><span class="lbl">Inclinación total</span><b id="tot">0°</b></div>
<p class="lbl">Si la burbuja se mueve al revés:</p>
<div class="pie">
<button id="invx" class="alt">Invertir ← →</button>
<button id="invy" class="alt">Invertir ↑ ↓</button>
</div>
</main>
<script>
const UART_SERVICE = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
const UART_TX = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
const $ = id => document.getElementById(id);
const MAX = 15; // grados que llegan al borde del nivel
const TOL = 1; // tolerancia para decir "nivelado"
let sR = 0, sP = 0, offR = 0, offP = 0, invX = false, invY = false;
let hayDatos = false, wasOk = false, buffer = "", conectando = false;
const espera = ms => new Promise(r => setTimeout(r, ms));
let paso = "", diag = "";
function estado(t, ayuda){ if (!/^Error/.test(t)) paso = t; $("estado").textContent = t; $("ayuda").textContent = ayuda || ""; }
function pintar(){
let r = sR - offR, p = sP - offP;
if (invX) r = -r;
if (invY) p = -p;
const tot = Math.hypot(r, p);
const v = $("vial"), b = $("burbuja");
const R = v.clientWidth / 2 - b.offsetWidth / 2;
let dx = -r / MAX * R, dy = p / MAX * R; // la burbuja sube hacia el lado alto
const d = Math.hypot(dx, dy);
if (d > R){ dx *= R / d; dy *= R / d; }
b.style.transform = "translate(" + dx.toFixed(1) + "px," + dy.toFixed(1) + "px)";
$("ax").textContent = Math.round(r) + "°";
$("ay").textContent = Math.round(p) + "°";
$("tot").textContent = tot.toFixed(1) + "°";
const ok = hayDatos && tot <= TOL;
v.classList.toggle("ok", ok);
$("aviso").textContent = !hayDatos ? "" : ok ? "¡Nivelado!" : tot < 3 ? "Casi…" : "Inclinado";
if (ok && !wasOk && navigator.vibrate) navigator.vibrate(40);
wasOk = ok;
}
function linea(texto){
const m = texto.match(/-?\d+/g); // "roll,pitch", por ejemplo "-3,5"
if (!m || m.length < 2) return;
const r = +m[0], p = +m[1];
if (!hayDatos){ sR = r; sP = p; hayDatos = true; }
else { sR += (r - sR) * 0.35; sP += (p - sP) * 0.35; } // suaviza el temblor
pintar();
}
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);
}
}
function explicar(e){
const nombre = e && e.name ? e.name : "Error";
const msg = e && e.message ? e.message : String(e);
let ayuda = "";
if (nombre === "NotFoundError" && /cancel/i.test(msg)) ayuda = "No has elegido ningún dispositivo. Pulsa Conectar y elige BBC micro:bit.";
else if (nombre === "NotFoundError") ayuda = "La micro:bit no tiene el servicio UART. Revisa el bloque «bluetooth servicio uart» y copia el .hex nuevo.";
else if (nombre === "NotSupportedError") ayuda = "No se pudo activar el envío de datos. Olvida la micro:bit en Ajustes → Bluetooth, borra la caché de la app «Bluetooth» del sistema y reinicia el móvil y la micro:bit.";
else if (nombre === "NetworkError") ayuda = "No se pudo abrir la conexión. Comprueba «No se requiere emparejamiento» en MakeCode, que no haya otra app conectada y reinicia la micro:bit.";
else if (nombre === "SecurityError") ayuda = "La página debe abrirse con https://.";
else if (nombre === "NotAllowedError") ayuda = "Permiso denegado. Permite el Bluetooth y los dispositivos cercanos para el navegador.";
estado("Error (" + nombre + "): " + msg,
"Falló en el paso: «" + paso + "». " + ayuda + (diag ? " Características: " + diag : ""));
}
async function conectarGatt(dev){
let ultimoError;
for (let intento = 1; intento <= 4; intento++){
try {
estado("Conectando… (intento " + intento + " 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;
}
$("conectar").onclick = async () => {
if (conectando) 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 micro:bit…");
const dev = await navigator.bluetooth.requestDevice({ acceptAllDevices: true, optionalServices: [UART_SERVICE] });
dev.addEventListener("gattserverdisconnected", () => {
hayDatos = false; pintar();
estado("Desconectado.", "Pulsa Conectar para volver a empezar.");
});
const svc = await conectarGatt(dev);
buffer = ""; hayDatos = false; diag = "";
estado("Leyendo las características del servicio…");
const chars = await svc.getCharacteristics();
diag = chars.map(c => c.uuid.slice(0, 8) + " [" + Object.keys(c.properties).filter(k => c.properties[k]).join(", ") + "]").join(" · ");
const candidatas = chars.filter(c => c.properties.notify || c.properties.indicate)
.sort((a, b) => (b.uuid === UART_TX) - (a.uuid === UART_TX));
if (!candidatas.length) throw new Error("El servicio UART no tiene ninguna característica que envíe datos.");
let ch = null, ultimoErr;
for (const c of candidatas){
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 || "micro:bit") + ". Apóyala en la superficie.");
} catch (e){
explicar(e);
} finally {
conectando = false; $("conectar").disabled = false;
}
};
$("cero").onclick = () => { offR = sR; offP = sP; pintar(); };
$("invx").onclick = () => { invX = !invX; pintar(); };
$("invy").onclick = () => { invY = !invY; pintar(); };
window.addEventListener("resize", pintar);
pintar();
</script>
</body>
</html>


