
Chismes del Lince 51: El osciloscopio del Lince
Sierra Bermeja, pasada la medianoche. El Lince de la Noche se había parado debajo de un poste de la luz, con una oreja pegada al metal y los ojos entornados.
—Hay algo que zumba —susurró—. Un mmmmm grave, siempre igual. Lo oigo, pero no lo veo. ¿Cómo será la electricidad por dentro?
—Será una onda —dijo la Gata Montesa, que bajaba del alcornoque de al lado sin hacer ruido—. Sube, baja, sube, baja. Cincuenta veces por segundo.
—Pues yo quiero verla. Necesito un osciloscopio. Uno de esos de laboratorio, con su pantallita verde y sus mandos. Cuestan una fortuna y pesan como un yunque. Ya me veo subiendo uno a la sierra…
La Gata se sentó, se lamió una pata y esperó, porque conocía esa mirada. Era la mirada del Lince cuando estaba a punto de complicarlo todo.
—Lince —dijo al fin—. ¿Qué llevas en la mochila?
—Un Arduino Mega, un cable USB, el portátil…
—Un Arduino Mega. Que tiene un conversor analógico-digital. Que mide voltios. Mil veces por segundo, o ocho mil si se lo pides bien.
—O sea que…
—O sea que tu osciloscopio ya lo llevas encima. Un osciloscopio Arduino que dibuje la onda en el móvil. Y, ya que estamos, que además la toque, para oír lo que ves.
El Lince miró el poste, miró la mochila y se rascó una oreja.
—Mira que soy listo —dijo—. Y mira que soy torpe.
Introducción
En este chisme construimos un osciloscopio Arduino: un aparato que mide un voltaje muchas veces por segundo y dibuja cómo cambia con el tiempo. El Arduino Mega hace la medida, un pequeño programa de Python hace de puente por el cable USB y el móvil dibuja la onda.
Además, el Arduino puede generar su propia señal. Desde el móvil eliges una frecuencia (100, 220, 440 Hz…), el Arduino la produce, la vuelve a medir y la dibuja. La comunicación va en los dos sentidos: del Arduino al móvil con las medidas, y del móvil al Arduino con las órdenes.
Y un detalle que lo hace especial: pulsas Escuchar y la onda congelada suena. Una línea naranja la recorre al ritmo del sonido, así que ves y oyes lo mismo a la vez.
Por qué este chisme
Un osciloscopio es el instrumento con el que se aprende electrónica: deja ver lo que de otro modo es invisible. Los de laboratorio son caros, pero el principio es sencillo y se puede construir con lo que ya tenemos.
Hasta ahora hemos leído sensores que nos daban un número cada cierto tiempo. Aquí damos un paso: medimos tan deprisa que vemos la forma de la señal, no solo su valor. Es la idea que estaba detrás del vigilante del ruido, pero llevada a una señal eléctrica.
Este chisme usa el mismo puente entre Arduino y móvil del chisme 50 (el giroscopio con Arduino), con una novedad: ahora también viaja información del móvil hacia el Arduino.
Cómo funciona un osciloscopio Arduino
La Gata plantea la primera pregunta: ¿cómo sabe el Arduino cuántos voltios hay en un cable? La respuesta es el conversor analógico-digital (ADC). Es un circuito dentro del chip que compara el voltaje del pin con una referencia de 5 V y devuelve un número entero entre 0 y 1023. Con 0 V da 0 y con 5 V da 1023.
Con 1024 niveles posibles, cada paso vale unos 5 V / 1023 ≈ 0,005 V (5 milivoltios). Esa es la resolución: no podemos distinguir cambios menores.
Una sola medida no dibuja una onda. Hace falta medir muchas veces, a intervalos iguales, y unir los puntos. Eso se llama muestreo, y cada medida es una muestra.
¿Cuán deprisa medimos?
Por defecto, analogRead() tarda unos 112 microsegundos: unas 9000 medidas por segundo. Nos interesa fijar un ritmo exacto, así que en el programa medimos cada 125 microsegundos, es decir, 8000 muestras por segundo. Para que el conversor llegue a ese ritmo con holgura, cambiamos su “divisor” (prescaler) a 64.
Cada captura son 400 muestras: 400 / 8000 = 0,05 s, es decir, 50 milisegundos de señal. En ese tiempo caben 2,5 ciclos de un zumbido de 50 Hz y 22 ciclos de una nota de 440 Hz.
¿Cómo conseguimos que la onda salga quieta?
Si cada captura empezara en un punto distinto de la onda, la imagen bailaría. Los osciloscopios resuelven esto con el disparo (trigger): esperan a que la señal cruce un nivel hacia arriba y empiezan a guardar justo ahí. Nuestro Arduino mira primero el mínimo y el máximo, toma el nivel intermedio y espera ese cruce. Así cada captura empieza igual y la onda se ve quieta.
¿Y la señal propia?
Para generar la onda cuadrada usamos tone(), que alterna el pin 8 entre 0 V y 5 V a la frecuencia que pidamos. Un cable del pin 8 al pin A0 y el Arduino se mide a sí mismo.
Qué necesitamos
- Un Arduino Mega 2560 con su cable USB.
- Un cable de puente (jumper) para unir el pin 8 con el pin A0.
- Un ordenador con el IDE de Arduino y Python 3 (con Thonny basta, que ya trae la biblioteca
pyserial). - Un móvil conectado a la misma red Wi-Fi que el ordenador.
No hace falta ningún sensor ni componente extra. El aparato es el propio Arduino.
Montaje
Hay dos montajes, y se cambia de uno a otro en un segundo:
- Para ver la señal del Arduino: un cable del pin 8 al pin A0. El pin 8 genera la onda y A0 la mide.
- Para ver el zumbido de la electricidad: quita ese cable, clava otro cable suelto en A0 y agarra el extremo metálico con el dedo.
En el segundo caso tu cuerpo funciona como una antena: capta la vibración de 50 Hz de los cables de la casa y la entrega a A0. Es una señal muy débil e inofensiva. Nunca conectes el Arduino a la red eléctrica ni a un enchufe: el zumbido se capta a distancia, sin tocar nada.
El Arduino Mega solo soporta entre 0 y 5 V en sus pines analógicos. Si metes más voltaje, lo estropeas.
Antes de nada: dos pruebas con el Serial Plotter
Antes de montar el puente conviene comprobar que el Arduino mide bien. Estas dos pruebas usan solo el IDE de Arduino. Abre Herramientas → Serial Plotter y elige 115200 baudios.
Prueba 1: el dedo oye la electricidad
Crea un programa nuevo en el IDE, copia este código, súbelo al Mega y agarra con el dedo un cable clavado en A0. Mide mil veces por segundo, así que verás una ondulación lenta y rugosa: es la señal de 50 Hz, aún poco definida.
// Chisme 51 (prueba 1) - El Lince oye la electricidad
// Arduino Mega. No hace falta conectar nada más que el cable USB.
//
// QUÉ HACE: lee el pin A0 mil veces por segundo y escribe cada valor (de 0 a 1023).
//
// MONTAJE: clava un cable en el pin A0 (o toca directamente el pin) y agarra
// la punta libre con los dedos. Tu cuerpo hace de antena.
//
// CÓMO VERLO: Herramientas > Serial Plotter, a 115200 baudios.
// Si en casa hay corriente alterna de 50 Hz, verás una onda que sube y baja 50 veces por segundo.
const int PIN = A0; // pin analógico que leemos
const unsigned long CADA_US = 1000; // una medida cada 1000 microsegundos = 1000 por segundo
unsigned long siguiente;
void setup() {
Serial.begin(115200);
siguiente = micros();
}
void loop() {
if ((long)(micros() - siguiente) < 0) return; // todavía no toca medir
siguiente += CADA_US;
Serial.println(analogRead(PIN)); // 0 = 0 voltios, 1023 = 5 voltios
}
Prueba 2: el Arduino se mide a sí mismo
Une el pin 8 con A0 con un cable. El pin 8 alterna entre 0 y 5 V cinco veces por segundo, así que el Plotter dibuja una onda cuadrada: arriba cerca de 1023 y abajo cerca de 0. Las partes de la resistencia y el condensador son opcionales; con dejar VER_SUAVE en false basta.
// Chisme 51 (prueba 2) - El Arduino genera su propia señal y la mide
// Arduino Mega.
//
// MONTAJE (haz primero el 1; el 2 es opcional):
// 1) Onda cuadrada de 5 Hz, sin piezas:
// un cable del pin 8 al pin A0
// 2) Onda suave de 2 Hz (hace falta una resistencia y un condensador):
// pin 9 -> resistencia -> pin A1
// condensador entre el pin A1 y GND
// (si es electrolítico: la pata larga (+) a A1 y la pata corta, la de la raya (-), a GND)
//
// CÓMO VERLO: Herramientas > Serial Plotter, a 115200 baudios.
const int PIN_CUADRADA = 8; // sale la onda cuadrada (0 V / 5 V)
const int PIN_PWM = 9; // sale la onda suave (antes de pasar por la resistencia y el condensador)
const bool VER_SUAVE = false; // cámbialo a true cuando hayas montado la resistencia y el condensador
const unsigned long CADA_US = 2000; // una medida cada 2 ms = 500 por segundo
unsigned long siguiente;
void setup() {
Serial.begin(115200);
pinMode(PIN_CUADRADA, OUTPUT);
pinMode(PIN_PWM, OUTPUT);
siguiente = micros();
}
void loop() {
if ((long)(micros() - siguiente) < 0) return; // todavía no toca
siguiente += CADA_US;
unsigned long ms = millis();
// Señal 1: 100 ms en alto y 100 ms en bajo = 5 veces por segundo
digitalWrite(PIN_CUADRADA, (ms / 100) % 2);
// Señal 2: una onda seno de 2 Hz (periodo de 500 ms) convertida en PWM de 0 a 255
float angulo = 2.0 * PI * (ms % 500) / 500.0;
analogWrite(PIN_PWM, (int)(127.5 + 127.5 * sin(angulo)));
// Medimos lo que llega a las entradas analógicas
Serial.print(analogRead(A0));
if (VER_SUAVE) {
Serial.print(',');
Serial.print(analogRead(A1));
}
Serial.println();
}
Si las dos pruebas funcionan, el Arduino mide bien y estamos listos para dibujar la onda en el móvil.
El código del Arduino
Este es el programa del osciloscopio Arduino. En el IDE: Archivo → Nuevo, pega el código y guárdalo con el nombre chisme51_osciloscopio. Placa: Arduino Mega or Mega 2560.
Lo importante está en tres funciones: capturar() (mide, dispara y guarda 400 muestras), enviar() (las manda por USB en una línea de texto) y leerOrdenes() (atiende al móvil cuando pide una frecuencia).
// Chisme 51 - El osciloscopio del Lince (parte 1: el Arduino Mega)
//
// QUÉ HACE
// 1) Mide el pin A0 con mucha rapidez: 8000 veces por segundo.
// 2) Espera a que la señal "cruce" su nivel medio subiendo (el DISPARO), para que la
// onda salga siempre quieta en la pantalla, como en un osciloscopio de verdad.
// 3) Guarda 400 medidas y las manda por el cable USB en una sola línea de texto:
// D,frecuencia_del_generador,medida1,medida2,...,medida400
// Cada medida va de 0 (0 voltios) a 1023 (5 voltios).
// 4) Además GENERA una señal: una onda cuadrada en el pin 8, con la frecuencia que pida el móvil.
//
// MONTAJE
// - Para ver la señal del propio Arduino: un cable del pin 8 al pin A0.
// - Para ver la electricidad de la casa: quita ese cable, clava otro en A0 y agárralo con el dedo.
//
// ÓRDENES (llegan por USB desde el puente): "F440" = generar 440 Hz, "F0" = apagar el generador.
//
// IMPORTANTE: cuando uses chisme51-puente.py, CIERRA el Monitor Serie del IDE de Arduino.
const int PIN_MEDIDA = A0; // aquí leemos la señal
const int PIN_GENERADOR = 8; // aquí sale la onda cuadrada
const int N = 400; // medidas por captura
const unsigned long PERIODO_US = 125; // 125 microsegundos entre medidas = 8000 por segundo
int16_t datos[N]; // las medidas de la última captura
int frecuencia = 440; // frecuencia del generador en Hz (0 = apagado)
unsigned long siguiente; // momento (en microsegundos) de la siguiente medida
char orden[8]; // para ir montando una orden como "F440"
int largoOrden = 0;
// Pone en marcha (o apaga) la onda cuadrada del pin 8
void aplicarGenerador() {
if (frecuencia >= 31 && frecuencia <= 4000) {
tone(PIN_GENERADOR, frecuencia);
} else {
noTone(PIN_GENERADOR);
frecuencia = 0;
}
}
// Lee lo que llega por USB. Una orden termina con un salto de línea.
void leerOrdenes() {
while (Serial.available() > 0) {
char c = Serial.read();
if (c == '\n') {
orden[largoOrden] = 0;
if (orden[0] == 'F') {
frecuencia = atoi(orden + 1);
aplicarGenerador();
}
largoOrden = 0;
} else if (c != '\r' && largoOrden < 7) {
orden[largoOrden++] = c;
}
}
}
// Espera al momento exacto de la siguiente medida y la hace
int medir() {
while ((long)(micros() - siguiente) < 0) { } // esperar
siguiente += PERIODO_US;
return analogRead(PIN_MEDIDA); // 0 = 0 V, 1023 = 5 V
}
void capturar() {
siguiente = micros();
// 1) Mirada previa: mínimo y máximo de la señal para fijar el nivel de disparo
int minimo = 1023, maximo = 0;
for (int i = 0; i < 160; i++) {
int v = medir();
if (v < minimo) minimo = v;
if (v > maximo) maximo = v;
}
int nivel = (minimo + maximo) / 2;
// 2) Disparo: esperamos a que la señal pase de estar por debajo del nivel a estar por encima.
// Si no pasa en unos 100 ms (señal plana), seguimos igualmente.
int previo = medir();
int v = previo;
boolean disparado = false;
for (int i = 0; i < 800 && !disparado; i++) {
v = medir();
if (previo < nivel && v >= nivel) disparado = true;
previo = v;
}
// 3) Guardamos N medidas seguidas
datos[0] = v;
for (int i = 1; i < N; i++) {
datos[i] = medir();
}
}
// Manda la captura en una línea: D,frecuencia,m1,m2,...
void enviar() {
Serial.print("D,");
Serial.print(frecuencia);
for (int i = 0; i < N; i++) {
Serial.print(',');
Serial.print(datos[i]);
}
Serial.println();
}
void setup() {
Serial.begin(115200);
pinMode(PIN_GENERADOR, OUTPUT);
// El conversor del Arduino mide por defecto unas 9600 veces por segundo, muy poco.
// Con este ajuste (divisor 64) puede llegar a unas 19000 y sobra para nuestras 8000.
ADCSRA = (ADCSRA & 0xF8) | 0x06;
aplicarGenerador();
Serial.println("# Osciloscopio listo: mide A0 a 8000 muestras por segundo");
}
void loop() {
leerOrdenes();
capturar();
enviar();
}
Lee y predice. ¿Qué pasaría si cambiaras PERIODO_US a 250? ¿Cuántos milisegundos de señal cabrían entonces en las 400 muestras? Pruébalo y comprueba si tu respuesta era correcta (ojo: la página del móvil supone 8000 muestras por segundo, así que también tendrías que cambiar FM allí).
El puente de Python
El Arduino habla por el cable USB, pero un móvil no puede leer ese cable. Hace falta alguien en medio: un programa de Python que lee el puerto serie del ordenador y reparte lo que llega por la red Wi-Fi.
Es el mismo puente del chisme 50 con una novedad. Ahora acepta órdenes del móvil (POST /orden) y las escribe en el puerto serie, así que la comunicación va en los dos sentidos. Guárdalo con el nombre chisme51-puente.py en una carpeta, y en esa misma carpeta guarda la página del siguiente apartado.
# Chisme 51 - El osciloscopio del Lince (parte 2: el puente en el ordenador)
#
# Es el mismo puente del chisme 50, con una novedad: ahora la comunicación va en los DOS sentidos.
# - Del Arduino al móvil: lee por el cable USB (puerto serie) las capturas que manda el Arduino
# y las reparte por Wi-Fi a todos los navegadores que miren la página.
# - Del móvil al Arduino: cuando pulsas un botón de frecuencia en la página, el puente
# escribe la orden (por ejemplo "F440") en el puerto serie.
#
# COMO USARLO
# - Deja este archivo y chisme51-osciloscopio.html en la MISMA carpeta.
# - Necesitas la biblioteca pyserial: pip install pyserial (Thonny ya la trae)
# - Cierra el Monitor Serie del IDE de Arduino (solo uno puede usar el puerto).
# - Ejecuta: python chisme51-puente.py
# o con puerto: python chisme51-puente.py --puerto COM5
# o SIN Arduino: python chisme51-puente.py --simular (datos inventados, para probar)
# - El programa escribe una direccion como http://192.168.1.35:8000
# Abrela en el navegador del movil (con el movil en la MISMA red Wi-Fi que el PC).
import argparse
import math
import os
import queue
import random
import re
import socket
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
CARPETA = os.path.dirname(os.path.abspath(__file__))
PAGINA = "chisme51-osciloscopio.html"
# Aqui guardamos una "cola" por cada navegador conectado
clientes = []
cerrojo = threading.Lock()
serie = None # el puerto serie abierto (None en el modo simulacion)
cerrojo_serie = threading.Lock() # para que no se mezclen dos ordenes a la vez
orden_sim = {"f": 440} # frecuencia que "pide" el movil en el modo simulacion
def difundir(linea):
"""Reparte una linea de datos a todos los navegadores conectados."""
with cerrojo:
for cola in clientes:
try:
cola.put_nowait(linea)
except queue.Full:
pass # un navegador muy lento se pierde algun dato; no frena a los demas
def enviar_orden(texto):
"""Manda una orden (por ejemplo F440) al Arduino, o al simulador."""
if serie is not None:
with cerrojo_serie:
serie.write((texto + "\n").encode("ascii"))
else:
orden_sim["f"] = int(texto[1:])
class Servidor(BaseHTTPRequestHandler):
def log_message(self, *args):
pass # no llenamos la consola de mensajes
def do_GET(self):
if self.path in ("/", "/index.html", "/" + PAGINA):
self.enviar_pagina()
elif self.path == "/datos":
self.enviar_datos()
else:
self.send_error(404)
def do_POST(self):
# El movil nos manda una orden: "F" seguida de la frecuencia (F440, F0...)
if self.path != "/orden":
self.send_error(404)
return
try:
largo = min(int(self.headers.get("Content-Length", 0)), 16)
except ValueError:
largo = 0
texto = self.rfile.read(largo).decode("ascii", "ignore").strip()
if not re.fullmatch(r"F\d{1,4}", texto):
self.send_error(400, "orden no valida") # solo aceptamos F y un numero
return
enviar_orden(texto)
self.send_response(204)
self.end_headers()
def enviar_pagina(self):
ruta = os.path.join(CARPETA, PAGINA)
try:
with open(ruta, "rb") as f:
contenido = f.read()
except OSError:
self.send_error(404, "Falta " + PAGINA + " junto a chisme51-puente.py")
return
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(contenido)))
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(contenido)
def enviar_datos(self):
# "Server-Sent Events": una conexion que se queda abierta y por la que
# vamos empujando lineas de texto al navegador segun llegan.
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "keep-alive")
self.end_headers()
cola = queue.Queue(maxsize=60)
with cerrojo:
clientes.append(cola)
try:
while True:
try:
linea = cola.get(timeout=10)
self.wfile.write(("data: " + linea + "\n\n").encode("utf-8"))
except queue.Empty:
self.wfile.write(b": sigo aqui\n\n") # latido para que no se corte
self.wfile.flush()
except OSError:
pass # el navegador se ha ido
finally:
with cerrojo:
clientes.remove(cola)
def ip_local():
"""Averigua la direccion del PC en la red (no envia nada, solo pregunta la ruta)."""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("10.255.255.255", 1))
return s.getsockname()[0]
except OSError:
return "127.0.0.1"
finally:
s.close()
def buscar_arduino():
from serial.tools import list_ports
for p in list_ports.comports():
texto = ((p.description or "") + " " + (p.manufacturer or "")).lower()
if p.vid in (0x2341, 0x2A03, 0x1A86, 0x0403) or "arduino" in texto:
return p.device
return None
def leer_serie(puerto):
global serie
import serial
try:
s = serial.Serial(puerto, 115200, timeout=1)
print("Leyendo el Arduino en", puerto)
time.sleep(2) # el Arduino se reinicia solo al abrir el puerto
serie = s # a partir de aqui ya podemos mandarle ordenes
while True:
linea = s.readline().decode("utf-8", "ignore").strip()
if linea:
difundir(linea)
except serial.SerialException as e:
print("\nSe ha perdido el puerto serie:", e)
print("Comprueba el cable y que el Monitor Serie del IDE este cerrado.")
os._exit(1)
def simular():
"""Capturas inventadas, como las que mandaria el Arduino (400 medidas a 8000 por segundo)."""
print("MODO SIMULACION: los datos son inventados.")
FM, N = 8000, 400
while True:
f = orden_sim["f"]
medidas = []
for i in range(N):
if f == 0:
# sin generador: un zumbido de 50 Hz pequeñito, como el del dedo
v = 110 + 22 * math.sin(2 * math.pi * 50 * i / FM) + random.gauss(0, 2)
else:
# onda cuadrada de frecuencia f: 5 V (1020) arriba y 0 V (3) abajo
v = (1020 if (i * f / FM) % 1 < 0.5 else 3) + random.gauss(0, 2)
medidas.append(str(max(0, min(1023, int(v)))))
difundir("D,%d,%s" % (f, ",".join(medidas)))
time.sleep(0.3)
def main():
ap = argparse.ArgumentParser(description="Puente Arduino <-> navegador (PC y movil)")
ap.add_argument("--puerto", help="puerto serie del Arduino, por ejemplo COM5")
ap.add_argument("--simular", action="store_true", help="datos inventados, sin Arduino")
ap.add_argument("--web", type=int, default=8000, help="puerto de la pagina (por defecto 8000)")
a = ap.parse_args()
if a.simular:
threading.Thread(target=simular, daemon=True).start()
else:
try:
import serial # noqa: F401
from serial.tools import list_ports
except ImportError:
sys.exit("Falta pyserial. Instalalo con: pip install pyserial")
puerto = a.puerto or buscar_arduino()
if not puerto:
print("No encuentro el Arduino. Puertos que veo:")
for p in list_ports.comports():
print(" ", p.device, "-", p.description)
sys.exit("Vuelve a ejecutar con: python chisme51-puente.py --puerto COMx")
threading.Thread(target=leer_serie, args=(puerto,), daemon=True).start()
servidor = ThreadingHTTPServer(("0.0.0.0", a.web), Servidor)
servidor.daemon_threads = True
print()
print("Abre esta direccion en el movil (misma red Wi-Fi):")
print(" http://%s:%d" % (ip_local(), a.web))
print("En este ordenador: http://localhost:%d" % a.web)
print("(Ctrl+C para parar)")
try:
servidor.serve_forever()
except KeyboardInterrupt:
print("\nAdios")
if __name__ == "__main__":
main()
Para ejecutarlo desde Thonny, usa el intérprete “Local Python 3” y el argumento --puerto COM5 (Ver → Argumentos del programa), cambiando COM5 por el puerto de tu Arduino. Si quieres probar sin Arduino, usa --simular.
La página del osciloscopio
Es un único archivo HTML. Guárdalo como chisme51-osciloscopio.html, junto a chisme51-puente.py. No hace falta publicarlo en Internet: el puente se lo ofrece al móvil por la red de casa o del aula.
Lo importante está en cuatro funciones de JavaScript: procesar() (recibe una captura), medir() (calcula voltajes y frecuencia), dibujar() (pinta la onda en el lienzo) y reproducir() (convierte la onda en sonido).
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>El osciloscopio del Lince</title>
<style>
:root {
--fondo: #f6f8f7;
--tarjeta: #ffffff;
--texto: #1c2b27;
--suave: #5d6f69;
--borde: #d7e0dc;
--onda: #0f8f7e; /* la señal */
--cursor: #e0762b; /* el cursor del sonido */
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--fondo); color: var(--texto);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; line-height: 1.4; }
main { max-width: 980px; margin: 0 auto; padding: 16px; }
h1 { font-size: 1.5rem; margin: 4px 0 2px; }
.sub { color: var(--suave); margin: 0 0 12px; font-size: .95rem; }
#estado { display: inline-block; padding: 4px 10px; border-radius: 999px; font-size: .85rem;
background: #fff3d6; border: 1px solid #ecd48f; margin-bottom: 10px; }
#estado.ok { background: #dff3ec; border-color: #9fd4c3; }
canvas { width: 100%; height: 280px; background: #fff; border: 1px solid var(--borde);
border-radius: 12px; display: block; }
.lecturas { display: flex; flex-wrap: wrap; gap: 8px 22px; margin: 10px 2px; font-size: .95rem;
font-variant-numeric: tabular-nums; }
.lecturas b { font-weight: 650; }
.tarjeta { background: var(--tarjeta); border: 1px solid var(--borde); border-radius: 12px;
padding: 12px; margin: 10px 0; }
.tarjeta h2 { font-size: 1rem; margin: 0 0 4px; }
.tarjeta p { margin: 2px 0 8px; font-size: .88rem; color: var(--suave); }
.botones { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
button { font: inherit; padding: 7px 14px; border-radius: 8px; border: 1px solid var(--borde);
background: #fff; cursor: pointer; }
button:hover { background: #eef4f1; }
button.activo { background: #dff3ec; border-color: #9fd4c3; font-weight: 650; }
#escuchar { background: var(--cursor); border-color: var(--cursor); color: #fff; font-weight: 650; }
select { font: inherit; padding: 6px; border-radius: 8px; border: 1px solid var(--borde); }
label { font-size: .9rem; }
details { background: var(--tarjeta); border: 1px solid var(--borde); border-radius: 12px;
padding: 8px 12px; margin: 10px 0; font-size: .92rem; }
summary { cursor: pointer; font-weight: 600; }
.nota { font-size: .86rem; color: var(--suave); }
</style>
</head>
<body>
<main>
<h1>El osciloscopio del Lince</h1>
<p class="sub">Mira y escucha una señal eléctrica: la que genera el propio Arduino o la electricidad de la casa.</p>
<span id="estado">Conectando con el puente…</span>
<canvas id="traza"></canvas>
<div class="lecturas">
<span>Frecuencia ≈ <b id="frec">—</b> Hz</span>
<span>Pico a pico: <b id="vpp">—</b> V</span>
<span>Mínimo: <b id="vmin">—</b> V</span>
<span>Máximo: <b id="vmax">—</b> V</span>
<span id="tasa"></span>
</div>
<section class="tarjeta">
<h2>Generador del Arduino (pin 8)</h2>
<p>Elige la frecuencia de la onda cuadrada. Estado actual: <b id="gen">?</b></p>
<div class="botones" id="botonesGen">
<button data-f="0">Apagado</button>
<button data-f="100">100 Hz</button>
<button data-f="220">220 Hz</button>
<button data-f="440">440 Hz</button>
<button data-f="880">880 Hz</button>
<button data-f="1500">1500 Hz</button>
</div>
<p class="nota">Por encima de unos 1500 Hz el osciloscopio empieza a engañarse: lo veremos otro día.</p>
</section>
<section class="tarjeta">
<h2>Escuchar la señal</h2>
<p>Congela la gráfica y repite la onda como sonido. Una línea naranja recorre la gráfica al ritmo del sonido. Baja antes el volumen del móvil.</p>
<div class="botones">
<button id="escuchar">▶ Escuchar</button>
<label>Velocidad
<select id="vel">
<option value="1">×1 (tal cual)</option>
<option value="2">×2</option>
<option value="4">×4</option>
<option value="8">×8</option>
</select></label>
<label><input type="checkbox" id="auto" checked> Ajustar la escala vertical a la señal</label>
</div>
<p class="nota">Con ×1 el sonido es la señal real. Con otra velocidad se acelera y el tono sube. El zumbido de 50 Hz casi no se oye en un móvil: prueba con ×4 u ×8.</p>
</section>
<details>
<summary>Montaje</summary>
<p><b>Señal del Arduino:</b> un cable del pin 8 al pin A0.</p>
<p><b>Electricidad de la casa:</b> quita ese cable, clava otro cable en A0 y agárralo con el dedo. Es inofensivo, pero <b>nunca</b> conectes nada a un enchufe.</p>
</details>
<p class="nota">Mide 8000 veces por segundo con 10 bits (de 0 a 1023, que equivalen a 0 a 5 V). Es un osciloscopio de juguete: sirve para aprender, no para medir con precisión.</p>
</main>
<script>
const FM = 8000; // muestras por segundo (lo decide el Arduino)
const $ = id => document.getElementById(id);
let bloque = null; // últimas medidas recibidas (valores de 0 a 1023)
let info = null; // mínimo, máximo, nivel y cruces de ese bloque
let congelado = false; // true mientras escuchamos
let genHz = null; // frecuencia que dice el Arduino que está generando
let hayDatos = false;
let ultimaLlegada = 0;
let contador = 0;
let audio = null, fuente = null, tramo = null; // para el sonido
function estado(texto, ok) {
$("estado").textContent = texto;
$("estado").className = ok ? "ok" : "";
}
// ---- Recibir una captura del Arduino: "D,frecuencia,medida1,medida2,..." ----
function procesar(linea) {
if (linea.charAt(0) === "#") { estado(linea.slice(1).trim(), false); return; }
if (linea.slice(0, 2) !== "D,") return;
const p = linea.split(",");
p.shift(); // quitamos la "D"
const gen = Number(p.shift());
const medidas = p.map(Number);
if (medidas.length < 100 || medidas.some(isNaN)) return; // línea rota: la ignoramos
ultimaLlegada = performance.now();
hayDatos = true;
contador++;
genHz = gen;
$("gen").textContent = gen === 0 ? "apagado" : gen + " Hz";
for (const b of $("botonesGen").children) {
b.classList.toggle("activo", Number(b.dataset.f) === gen);
}
if (!congelado) {
bloque = medidas;
medir();
}
}
// ---- Medir la captura: voltajes y frecuencia ----
function aVoltios(cuentas) { return cuentas * 5 / 1023; }
function medir() {
let mn = 1e9, mx = -1e9;
for (const x of bloque) { if (x < mn) mn = x; if (x > mx) mx = x; }
const nivel = (mn + mx) / 2;
// Cruces ascendentes por el nivel medio, con la posición exacta entre dos medidas
// (con "histéresis": solo cuenta si antes la señal bajó claramente, para que el ruido no cuente de más)
const cruces = [];
const suelo = nivel - (mx - mn) / 4;
let armado = false;
for (let i = 1; i < bloque.length; i++) {
if (bloque[i] < suelo) armado = true;
if (armado && bloque[i - 1] < nivel && bloque[i] >= nivel) {
cruces.push(i - 1 + (nivel - bloque[i - 1]) / (bloque[i] - bloque[i - 1]));
armado = false;
}
}
info = { mn, mx, nivel, cruces };
$("vmin").textContent = aVoltios(mn).toFixed(2);
$("vmax").textContent = aVoltios(mx).toFixed(2);
$("vpp").textContent = aVoltios(mx - mn).toFixed(2);
// Frecuencia = ciclos completos / tiempo que han tardado. Solo si hay una onda clara.
if (mx - mn >= 8 && cruces.length >= 2) {
const ciclos = cruces.length - 1;
const muestras = cruces[cruces.length - 1] - cruces[0];
$("frec").textContent = (FM * ciclos / muestras).toFixed(1);
} else {
$("frec").textContent = "—";
}
}
// ---- Dibujar la gráfica ----
const lienzo = $("traza");
const ctx = lienzo.getContext("2d");
function dibujar() {
const dpr = window.devicePixelRatio || 1;
const w = lienzo.clientWidth, h = lienzo.clientHeight;
if (lienzo.width !== Math.round(w * dpr)) { lienzo.width = Math.round(w * dpr); lienzo.height = Math.round(h * dpr); }
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const izq = 54, der = 10, arr = 12, aba = 28;
const ancho = w - izq - der, alto = h - arr - aba;
ctx.font = "11.5px system-ui, sans-serif";
if (!bloque) {
ctx.fillStyle = "#5d6f69";
ctx.fillText("Esperando datos del Arduino…", izq, h / 2);
return;
}
// Escala vertical: de 0 a 1023 cuentas, o ajustada a la señal
let lo = 0, hi = 1023;
if ($("auto").checked) {
const rango = Math.max(info.mx - info.mn, 20);
const centro = (info.mx + info.mn) / 2;
lo = centro - rango * 0.65;
hi = centro + rango * 0.65;
}
const Y = v => arr + (hi - v) / (hi - lo) * alto;
const X = i => izq + i / (bloque.length - 1) * ancho;
// Cuadrícula horizontal con voltios
ctx.strokeStyle = "#e3eae7"; ctx.fillStyle = "#5d6f69"; ctx.lineWidth = 1;
for (let k = 0; k <= 4; k++) {
const v = lo + (hi - lo) * k / 4;
ctx.beginPath(); ctx.moveTo(izq, Y(v)); ctx.lineTo(w - der, Y(v)); ctx.stroke();
ctx.fillText(aVoltios(v).toFixed($("auto").checked ? 2 : 1) + " V", 4, Y(v) + 4);
}
// Cuadrícula vertical con milisegundos
const total = bloque.length / FM * 1000; // duración de la captura en ms
const pasos = [0.5, 1, 2, 5, 10, 20];
const paso = pasos.find(p => total / p <= 6) || 20;
for (let t = 0; t <= total; t += paso) {
const x = izq + t / total * ancho;
ctx.beginPath(); ctx.moveTo(x, arr); ctx.lineTo(x, arr + alto); ctx.stroke();
ctx.fillText(t + " ms", x - 12, h - 8);
}
// Línea del disparo (el nivel medio)
ctx.setLineDash([4, 4]); ctx.strokeStyle = "#8a97a0";
ctx.beginPath(); ctx.moveTo(izq, Y(info.nivel)); ctx.lineTo(w - der, Y(info.nivel)); ctx.stroke();
ctx.setLineDash([]);
// La señal
ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue("--onda").trim();
ctx.lineWidth = 2;
ctx.beginPath();
bloque.forEach((v, i) => { i ? ctx.lineTo(X(i), Y(v)) : ctx.moveTo(X(i), Y(v)); });
ctx.stroke();
// El cursor del sonido
if (fuente && tramo) {
const fase = ((audio.currentTime - tramo.t0) % tramo.dur) / tramo.dur;
const x = X(tramo.a + fase * (tramo.b - tramo.a));
ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue("--cursor").trim();
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(x, arr); ctx.lineTo(x, arr + alto); ctx.stroke();
}
}
// ---- Sonido: repetimos un número entero de ciclos de la onda, sin saltos ----
function reproducir() {
const c = info.cruces;
const a = Math.round(c[0]), b = Math.round(c[c.length - 1]);
const tramoMuestras = bloque.slice(a, b);
const media = tramoMuestras.reduce((s, x) => s + x, 0) / tramoMuestras.length;
let pico = 1;
for (const x of tramoMuestras) pico = Math.max(pico, Math.abs(x - media));
const vel = Number($("vel").value);
const buf = audio.createBuffer(1, tramoMuestras.length, FM * vel);
const d = buf.getChannelData(0);
for (let i = 0; i < tramoMuestras.length; i++) d[i] = 0.7 * (tramoMuestras[i] - media) / pico;
fuente = audio.createBufferSource();
fuente.buffer = buf;
fuente.loop = true;
const g = audio.createGain();
g.gain.value = 0.25;
fuente.connect(g).connect(audio.destination);
fuente.start();
tramo = { a, b, dur: tramoMuestras.length / (FM * vel), t0: audio.currentTime };
}
function parar() {
if (fuente) { fuente.stop(); fuente = null; }
congelado = false;
$("escuchar").textContent = "▶ Escuchar";
}
$("escuchar").addEventListener("click", () => {
if (fuente) { parar(); return; }
if (!bloque || info.mx - info.mn < 8 || info.cruces.length < 2) {
estado("No hay una onda que repetir: prueba con el generador o con el dedo en A0.", false);
return;
}
if (!audio) audio = new (window.AudioContext || window.webkitAudioContext)();
audio.resume();
congelado = true;
reproducir();
$("escuchar").textContent = "■ Seguir en directo";
});
$("vel").addEventListener("change", () => {
if (fuente) { fuente.stop(); reproducir(); }
});
// ---- Botones del generador: mandamos la orden al puente ----
$("botonesGen").addEventListener("click", e => {
const f = e.target.dataset.f;
if (f === undefined) return;
fetch("/orden", { method: "POST", body: "F" + f })
.catch(() => estado("No se pudo mandar la orden al puente", false));
});
// ---- Bucle de dibujo y avisos ----
function fotograma() {
dibujar();
if (hayDatos && performance.now() - ultimaLlegada > 3000) {
estado("Conectado, pero no llegan datos del Arduino (¿cable? ¿Monitor Serie abierto?)", false);
hayDatos = false;
}
requestAnimationFrame(fotograma);
}
requestAnimationFrame(fotograma);
setInterval(() => {
$("tasa").textContent = contador ? contador + " capturas/s" : "";
if (contador && $("estado").className !== "ok") estado("Recibiendo datos del Arduino", true);
contador = 0;
}, 1000);
// ---- Conexión con chisme51-puente.py ----
const origen = new EventSource("/datos");
origen.onopen = () => estado("Conectado al puente. Esperando datos…", false);
origen.onerror = () => estado("Sin conexión con el puente (¿sigue abierto chisme51-puente.py?)", false);
origen.onmessage = e => procesar(e.data);
</script>
</body>
</html>
Cuando todo está en marcha, abre en el móvil la dirección que escribe el puente en la consola (algo como http://192.168.1.35:8000).
Cómo probarlo
- Sube
chisme51_osciloscopioal Arduino Mega. Une el pin 8 con A0. - Cierra el Monitor Serie y el Serial Plotter del IDE. Solo un programa a la vez puede usar el puerto.
- Ejecuta
chisme51-puente.pycon--puerto COM5(o el puerto de tu Arduino). - Abre en el móvil la dirección que aparece en la consola. Si solo quieres comprobar la página, ejecútalo con
--simular. - Pulsa 440 Hz: la página debe mostrar una onda cuadrada y “Frecuencia ≈ 440” (con pequeñas diferencias).
- Prueba 100, 220, 880 y 1500 Hz. Observa cuántos ciclos caben en la pantalla en cada caso.
- Pulsa Escuchar. La onda se congela, suena en bucle y una línea naranja la recorre. Baja antes el volumen del móvil.
- Pulsa Apagado, quita el cable del pin 8 y agarra con el dedo un cable clavado en A0. Aparece una ondulación de unos 50 Hz.
El zumbido de 50 Hz casi no se oye en el altavoz de un móvil, porque esos altavoces apenas reproducen sonidos tan graves. Si cambias la velocidad a ×4 o ×8, el mismo trozo de onda se reproduce más rápido y el tono sube hasta un rango audible.


Qué estamos midiendo realmente
La Gata hace aquí la pregunta clave: —¿Eso que dibujas son voltios, o son números?
Son números. El conversor entrega valores entre 0 y 1023 y la página los convierte a voltios con una regla de tres: voltios = número × 5 / 1023. Esa conversión da por supuesto que la referencia del Arduino es exactamente 5 V, y esa referencia viene de la alimentación USB, que puede variar un poco. Por eso el osciloscopio Arduino sirve para ver la forma de la señal, pero no para medir un voltaje con precisión.
También hay un límite de resolución (unos 5 mV por paso) y un límite de tiempo. Con 8000 muestras por segundo, entre una muestra y la siguiente pasan 125 microsegundos, así que no podemos ver nada que cambie más rápido que eso. A 1500 Hz ya salen solo unas 5 muestras por ciclo: la onda cuadrada se ve como un serrucho.
Y una pregunta de ciencia: el zumbido de 50 Hz que aparece con el dedo, ¿es la electricidad de la casa o es tu cuerpo? Es la electricidad que hay en el aire, captada por tu cuerpo, que hace de antena. Medir algo no es lo mismo que entender de dónde viene: para comprobarlo habría que repetir la prueba lejos de cables y en otra habitación.
Hay un asunto más fino que el Lince descubrirá en otro chisme: qué pasa cuando la señal cambia demasiado rápido para el ritmo de muestreo. Spoiler: el osciloscopio se engaña, y de forma muy curiosa.
Para el aula
Este chisme da mucho juego en clase porque la señal se ve y se oye. Algunas actividades concretas:
- Frecuencia y periodo. Con el generador a 100 Hz, mide en la cuadrícula cuántos milisegundos dura un ciclo. ¿Cuánto da? Comprueba que periodo = 1 / frecuencia y repite con 220, 440 y 880 Hz.
- Predecir antes de mirar. ¿Cuántos ciclos de 440 Hz caben en los 50 ms de una captura? Calcula y compara con la pantalla.
- Resolución. Si el conversor tiene 1024 niveles y mide hasta 5 V, ¿cuál es el cambio más pequeño que puede distinguir? ¿Y si midiéramos hasta 3,3 V?
- Dos dispositivos, dos medidas. Un grupo usa el dedo en A0 y otro un cable más largo. ¿Sale el mismo pico a pico? ¿Y la misma frecuencia? ¿Qué variable ha cambiado?
- Hipótesis y experimento. ¿Depende el zumbido de 50 Hz de lo cerca que estés de un enchufe? Diseña una prueba: qué variable cambias, cuáles mantienes y cuántas veces repites.
- Dato y medida. El número 512 es un dato; ¿es un voltaje? ¿Qué hay que suponer para decir que son 2,5 V?
Retos
- Haz que funcione. Monta el pin 8 con A0 y consigue ver y escuchar la onda de 440 Hz en el móvil.
- Modifica algo. Cambia los botones de la página para añadir una frecuencia nueva (por ejemplo, 330 Hz). Tendrás que tocar el HTML.
- Cambia el disparo. En el código del Arduino, haz que el disparo sea con la señal bajando en vez de subiendo. ¿Se ve distinta la onda?
- Guarda los datos. Añade un botón que descargue las 400 muestras en un archivo CSV.
- Representa los datos. Abre ese CSV en una hoja de cálculo y haz una gráfica. ¿Coincide con la del móvil?
- Automatiza. Modifica el puente para que guarde una captura por minuto durante una hora con la hora de cada una.
- Haz que otro dispositivo participe. Abre la página en dos móviles a la vez. ¿Ven lo mismo? ¿Qué pasa si uno pulsa Escuchar?
- Conviértelo en investigación. Mide el zumbido de 50 Hz en varios lugares del instituto con el mismo montaje y compara el pico a pico. ¿Qué conclusiones puedes sacar y cuáles no?
Qué hemos aprendido
Un osciloscopio Arduino es posible porque un Arduino mide voltios con un conversor, y si mide muy deprisa y a intervalos iguales, puede dibujar la forma de una señal. Hemos visto el muestreo, el disparo que mantiene la onda quieta, la resolución de 10 bits, y cómo un navegador puede enviar órdenes al Arduino y recibir sus medidas.
Lo más importante: es un instrumento de juguete que enseña a pensar como un científico. Medir no es lo mismo que medir bien, y todo instrumento tiene límites que conviene conocer.
El Lince volvió a mirar el poste de la luz. Ahora, en la pantalla de su móvil, el zumbido tenía forma de ola. La Gata Montesa sonrió.
—Mira que eres torpe, Lince.
—Ya —dijo él, sin apartar los ojos de la onda—. Pero qué bonito se ve.
Para seguir investigando
- Medir el sonido en vez de la electricidad: el vigilante del ruido usa el micrófono del móvil.
- Otra señal que se ve como una onda: el sismógrafo de la mano dibuja el temblor.
- Guardar y comparar datos a lo largo del tiempo: la estación del tiempo con micro:bit.
- Fuente externa: la referencia oficial de analogRead() en Arduino y la explicación de qué es un osciloscopio en Wikipedia.
En el próximo chisme veremos qué pasa cuando la señal cambia más rápido de lo que el Arduino puede mirar. Spoiler: el osciloscopio se engaña.


