Tres proyectos AtomVM de punta a punta: máquina arcade, Coliseo Atómico y Tagboard
Tres proyectos AtomVM de punta a punta: máquina arcade, Coliseo Atómico y Tagboard
En el capítulo 14 terminamos de armar la caja de herramientas: aprendimos a escribir NIFs y puertos para bajar a C cuando el BEAM no alcanza, vimos Popcorn para llevar Elixir al navegador, y montamos control remoto sobre WiFi para mandar comandos a un ESP32 desde otra máquina. Cada una de esas piezas se explicó de forma aislada, con un ejemplo mínimo que cabía en una pantalla.
Este capítulo hace lo contrario. Toma tres proyectos completos —construidos por la comunidad de Elixir Chile sobre AtomVM— y los desarma capa por capa: qué problema resuelven, qué hardware necesitan, cómo se conecta, qué código corre en el microcontrolador, qué corre fuera de él y por qué se decidió así. El objetivo no es que copies los tres proyectos, sino que veas cómo se compone un sistema real a partir de las piezas de los capítulos anteriores, y que reconozcas los patrones que aparecen cuando uno pasa de “enciende un LED” a “esto lo puedo mostrar en una feria”.
Los tres proyectos son deliberadamente distintos entre sí:
- La máquina arcade es un problema de interfaz: hardware físico que se hace pasar por un teclado frente a un videojuego que no sabe nada de ESP32.
- El Coliseo Atómico es un problema de actuación y seguridad: motores, potencia, radio y qué hacer cuando el enlace se cae.
- El Tagboard es un problema de servicio: un servidor HTTP con persistencia corriendo dentro de 4 MB de flash.
Panorama comparativo
Antes de entrar en detalle conviene verlos lado a lado.
| Dimensión | Máquina arcade | Coliseo Atómico | Tagboard |
|---|---|---|---|
| Problema central | Traducir entrada física a eventos de teclado | Mover un robot de combate y sobrevivir al impacto | Servir una app web desde el microcontrolador |
| Rol del ESP32 | Sensor + actuador de feedback | Controlador de potencia y locomoción | Servidor HTTP con almacenamiento |
| Sensores | Potenciómetro (ADC), 2 botones (GPIO) | Módulo de obstáculos, DHT11 opcional | Ninguno |
| Actuadores | LED RGB por PWM | 2 motores DC, servo SG90, LEDs | Ninguno (salida por HTTP) |
| Comunicación | UART serial hacia el PC | WiFi o Bluetooth hacia el mando | WiFi, HTTP hacia el navegador |
| Software fuera del ESP32 | Daemon en Go + TIC-80 | App o mando remoto | Frontend con Web Components |
| Persistencia | Ninguna | Ninguna (o calibración en NVS) | NVS con term_to_binary |
| Dificultad eléctrica | Baja | Alta (puente H, corrientes de motor) | Nula |
| Dificultad de software | Media (protocolo y sincronía) | Media (control y failsafe) | Alta (HTTP, estado, frontend) |
| Uso típico | Demo de feria, stand interactivo | Competencia educativa | Cartelera de sala, kiosco |
Una lectura útil de la tabla: la dificultad eléctrica y la dificultad de software no van de la mano. El Tagboard no tiene un solo cable soldado y es el más complejo de programar; el Coliseo tiene código simple y es el que más te puede quemar una placa. Cuando planifiques tu propio proyecto, estima ambos ejes por separado.
Proyecto 1: la máquina arcade
La idea
Existe un videojuego de carreras corriendo en un PC. Es un juego hecho para TIC-80, una consola de fantasía que ejecuta cartuchos escritos en Lua y que responde a las flechas del teclado y a las teclas Z y X. El juego no tiene la menor idea de que existe un ESP32.
Lo que queremos es jugarlo con un volante físico —un potenciómetro de eje largo— y dos botones arcade, y que un LED RGB montado en el gabinete cambie de color según lo que ocurre dentro del juego: azul cuando alcanzas un hito de puntaje, rojo cuando chocas.
Fíjate en que hay dos flujos de información en direcciones opuestas: hacia el juego, la posición del potenciómetro y el estado de los botones deben convertirse en pulsaciones de teclado; desde el juego, los eventos internos del cartucho —choque, hito de puntaje— deben convertirse en colores del LED.
Arquitectura en tres capas
flowchart LR
subgraph HW["Capa 1: ESP32 con AtomVM"]
POT["Potenciometro 500K<br/>GPIO32 (ADC1)"]
BTN["2 botones<br/>GPIO26 y GPIO25"]
LED["LED RGB por PWM<br/>GPIO22, 21, 23"]
end
subgraph DAE["Capa 2: daemon en Go (PC)"]
SER["Lector serial"]
KEY["Emisor de teclas<br/>keybd_event"]
OUT["Lector de stdout"]
end
subgraph GAME["Capa 3: TIC-80"]
LUA["Cartucho Lua<br/>car_adventure.tic"]
end
POT -->|"pot:1830"| SER
BTN -->|"btn:z:down"| SER
SER --> KEY
KEY -->|"eventos de teclado<br/>del sistema operativo"| LUA
LUA -->|"trace event:explode<br/>por stdout"| OUT
OUT -->|"red / green / blue<br/>por serial"| LED
Las tres capas se comunican por interfaces deliberadamente tontas: texto plano por serial entre 1 y 2, eventos de teclado del sistema operativo entre 2 y 3 en un sentido, y líneas por stdout entre 3 y 2 en el otro. Ninguna capa sabe nada de las internas de las otras. Eso es lo que permite cambiar el juego sin tocar el firmware, o cambiar el hardware sin tocar el juego.
Lista de materiales y pinout
| Componente | Cantidad | Función en el proyecto |
|---|---|---|
| ESP32 DevKit (30 pines) | 1 | Corre AtomVM, lee sensores y maneja el LED |
| Potenciómetro 500 kΩ | 1 | Hace de volante; se lee por ADC |
| LED RGB 5 mm cátodo común | 1 | Feedback visual del estado del juego |
| Botón táctil (push button) | 2 | Equivalen a las teclas Z y X |
| Resistencia 220 Ω | 3 | Limitan corriente en cada canal del LED |
| Protoboard y jumpers | 1 set | Montaje sin soldadura |
| Cable micro USB | 1 | Alimentación y puerto serial |
El mapa de pines usado en el proyecto original:
| GPIO | Dirección | Conectado a | Notas |
|---|---|---|---|
| 32 | Entrada analógica | Terminal central del potenciómetro | ADC1, seguro de usar con WiFi |
| 26 | Entrada digital | Botón 1 (tecla Z) | Con resistencia pull-up interna |
| 25 | Entrada digital | Botón 2 (tecla X) | Con resistencia pull-up interna |
| 22 | Salida PWM | Canal rojo del LED | A través de 220 Ω |
| 21 | Salida PWM | Canal verde del LED | A través de 220 Ω |
| 23 | Salida PWM | Canal azul del LED | A través de 220 Ω |
| 3V3 | Alimentación | Extremo del potenciómetro | Nunca 5 V: el ADC se daña |
| GND | Común | Cátodo del LED, botones, potenciómetro | Masa compartida |
Un detalle que aparece en el capítulo 6 y que aquí es crítico: el ESP32 tiene dos unidades de ADC, y ADC2 queda inutilizable cuando el WiFi está activo. Los pines 32 a 39 pertenecen a ADC1, así que elegir el GPIO 32 no es casual: es la diferencia entre un proyecto que funciona y uno que devuelve lecturas de cero apenas levantas la red. Con los botones cableados a masa y el pull-up interno activo, además, el pin lee :high en reposo y :low cuando se presiona; es lógica invertida y hay que tenerlo presente al leer el código.
El firmware en Elixir
El firmware tiene cuatro responsabilidades independientes, y esa palabra —independientes— es la que decide la arquitectura del programa. En AtomVM cada responsabilidad va en su propio proceso: si el lector del ADC se traba, los botones siguen respondiendo.
flowchart TD
MAIN["Arcade.start/0"] --> LEDP["Proceso Led<br/>estado del color actual"]
MAIN --> POTP["Proceso Potenciometro<br/>muestrea cada 200 ms"]
MAIN --> BTNP["Proceso Botones<br/>polling + debounce 50 ms"]
MAIN --> UARTP["Proceso Uart<br/>recibe comandos del daemon"]
POTP -->|"escribe lineas"| SERIAL["Puerto UART 115200"]
BTNP -->|"escribe lineas"| SERIAL
UARTP -->|"comando de color"| LEDP
LEDP -->|"set_duty"| LEDC["Periferico LEDC (PWM)"]
Empecemos por el módulo del LED. El LED RGB se maneja con PWM: cada canal recibe una señal cuadrada cuyo ciclo de trabajo determina el brillo percibido. AtomVM expone el periférico LEDC del ESP32 a través del módulo :ledc.
defmodule Arcade.Led do
@moduledoc """
Controla un LED RGB de catodo comun mediante tres canales LEDC.
Mantiene el color actual en el estado del proceso.
"""
@speed_mode 0
@timer 0
@freq_hz 5000
@resolution 13
@max_duty 8191
# {nombre, canal LEDC, GPIO}
@canales [{:rojo, 0, 22}, {:verde, 1, 21}, {:azul, 2, 23}]
def start do
spawn(fn ->
configurar_hardware()
escribir(0, 0, 0)
loop(:apagado)
end)
end
defp configurar_hardware do
:ledc.timer_config(
duty_resolution: @resolution, freq_hz: @freq_hz,
speed_mode: @speed_mode, timer_num: @timer
)
Enum.each(@canales, fn {_nombre, canal, gpio} ->
:ledc.channel_config(
channel: canal, duty: 0, gpio_num: gpio,
speed_mode: @speed_mode, hpoint: 0, timer_sel: @timer
)
end)
end
defp loop(color_actual) do
receive do
{:color, color} when color != color_actual ->
pintar(color)
loop(color)
{:color, _mismo} ->
loop(color_actual)
{:pulso, color, milisegundos} ->
pintar(color)
Process.send_after(self(), {:color, color_actual}, milisegundos)
loop(color_actual)
end
end
defp pintar(:rojo), do: escribir(@max_duty, 0, 0)
defp pintar(:verde), do: escribir(0, @max_duty, 0)
defp pintar(:azul), do: escribir(0, 0, @max_duty)
defp pintar(:apagado), do: escribir(0, 0, 0)
defp escribir(r, g, b) do
Enum.zip(@canales, [r, g, b])
|> Enum.each(fn {{_nombre, canal, _gpio}, duty} ->
:ledc.set_duty(@speed_mode, canal, duty)
:ledc.update_duty(@speed_mode, canal)
end)
end
end
Tres cosas del módulo merecen atención. La resolución de 13 bits hace que el ciclo de trabajo vaya de 0 a 8191; la relación entre resolución y frecuencia no es libre, porque a mayor frecuencia quedan menos bits disponibles —el contador del temporizador debe completar su ciclo dentro del período—, y a 5 kHz caben cómodamente 13 bits. El proceso ignora el mensaje si el color no cambió: escribir el mismo duty repetidamente no rompe nada, pero evitarlo ahorra llamadas al periférico. Y {:pulso, color, ms} implementa un destello temporal con Process.send_after/3 sobre el propio proceso, que es el patrón de “temporizador sin bloquear”: nada de Process.sleep/1 dentro de un receive.
Ahora el potenciómetro. La lectura cruda del ADC es ruidosa: aunque no toques el eje, el valor oscila unas decenas de unidades, y si mandáramos cada lectura por serial el daemon vería un volante temblando. La solución es un umbral de cambio: solo se reporta cuando la lectura se aleja lo suficiente de la última reportada.
defmodule Arcade.Potenciometro do
@moduledoc "Muestrea GPIO32 y reporta la posicion solo cuando el cambio supera un umbral."
@pin 32
@unidad 1
@periodo_ms 200
@umbral 50
def start(uart) do
spawn(fn ->
:ok = :esp_adc.start()
{:ok, canal} = :esp_adc.acquire(@pin, @unidad)
loop(uart, canal, -1)
end)
end
defp loop(uart, canal, ultimo) do
Process.sleep(@periodo_ms)
case leer(canal) do
{:ok, valor} when abs(valor - ultimo) >= @umbral ->
Arcade.Uart.enviar(uart, "pot:#{valor}")
loop(uart, canal, valor)
_ ->
loop(uart, canal, ultimo)
end
end
defp leer(canal) do
case :esp_adc.sample(canal, @unidad, [:raw]) do
{:ok, [raw: valor]} -> {:ok, valor}
{:ok, valor} when is_integer(valor) -> {:ok, valor}
_otro -> :error
end
end
end
La API de ADC de AtomVM ha cambiado entre versiones: hay firmwares donde la lectura se hace con una función de un solo argumento y otros donde se abre explícitamente un canal antes de muestrear. Por eso leer/1 acepta las dos formas de respuesta en lugar de asumir una; cuando adaptes este código, revisa la documentación del módulo esp_adc para la versión de AtomVM que hayas flasheado y ajusta solo esa función. Sobre los números elegidos: 200 ms de período dan cinco lecturas por segundo, suficiente para un volante y sin saturar el serial; el umbral de 50 unidades filtra el ruido típico del ADC sin perder giros intencionales; y el rango crudo va de 0 a 4095 porque el ADC del ESP32 trabaja a 12 bits por defecto.
Los botones usan el mismo principio de filtrado, pero por una razón física distinta. Un pulsador mecánico no cambia de estado limpiamente: durante unos milisegundos los contactos rebotan y el pin ve una ráfaga de transiciones. Sin filtrar, un solo apretón se convierte en cinco pulsaciones de tecla.
defmodule Arcade.Botones do
@moduledoc "Lee dos pulsadores con pull-up y reporta transiciones ya filtradas."
@botones [{26, "z"}, {25, "x"}]
@debounce_ms 50
def start(uart) do
spawn(fn ->
:gpio.open()
Enum.each(@botones, fn {pin, _t} ->
:gpio.set_pin_mode(pin, :input)
:gpio.set_pin_pull(pin, :up)
end)
loop(uart, Enum.map(@botones, fn {pin, t} -> {pin, t, :arriba, 0} end))
end)
end
defp loop(uart, estados) do
Process.sleep(10)
ahora = :erlang.system_time(:millisecond)
nuevos =
Enum.map(estados, fn {pin, tecla, estado, cambio} = actual ->
# Con pull-up, :low significa presionado.
leido = if :gpio.digital_read(pin) == :low, do: :abajo, else: :arriba
if leido == estado or ahora - cambio < @debounce_ms do
actual
else
evento = if leido == :abajo, do: "down", else: "up"
Arcade.Uart.enviar(uart, "btn:#{tecla}:#{evento}")
{pin, tecla, leido, ahora}
end
end)
loop(uart, nuevos)
end
end
El debounce implementado aquí es “por tiempo de estabilidad”: una vez aceptada una transición, se ignora cualquier otra durante 50 ms. Es la variante más simple y suficiente para pulsadores táctiles; si el pulsador fuera un microswitch de palanca de arcade, con rebotes más largos, subirías el valor a 80 o 100 ms. Falta el UART, que es la vía de salida hacia el daemon y de entrada desde él.
defmodule Arcade.Uart do
@moduledoc "Abre el UART0 (el del USB) a 115200 baud y encapsula el protocolo de lineas."
@dispositivo "UART0"
@velocidad 115_200
def start(led) do
pid =
:uart.open(@dispositivo,
speed: @velocidad,
data_bits: 8,
stop_bits: 1,
parity: :none,
event_handler: self()
)
spawn(fn -> loop(pid, led) end)
pid
end
def enviar(pid, linea), do: :uart.write(pid, linea <> "\n")
defp loop(pid, led) do
receive do
{:uart, ^pid, datos} ->
datos
|> :binary.split("\n", [:global, :trim_all])
|> Enum.each(&procesar(&1, led))
loop(pid, led)
end
end
defp procesar("red", led), do: send(led, {:pulso, :rojo, 1500})
defp procesar("blue", led), do: send(led, {:pulso, :azul, 800})
defp procesar("green", led), do: send(led, {:color, :verde})
defp procesar("off", led), do: send(led, {:color, :apagado})
defp procesar(otro, _led), do: :io.format(~c"Comando desconocido: ~s~n", [otro])
end
El detalle importante está en :binary.split/3 con :global. El UART entrega los bytes tal como llegan, sin respetar tus límites lógicos: puedes recibir "re" en un mensaje y "d\nbl" en el siguiente. Un lector serial correcto siempre acumula y separa por el delimitador; asumir que cada mensaje recibido es una línea completa funciona en el escritorio y falla en la demo. Si quieres ser estricto, el loop/2 debe llevar un acumulador: concatenas lo recibido con el resto pendiente, separas por \n, procesas todas las partes menos la última y guardas esa última —el fragmento incompleto— para el próximo mensaje.
El punto de entrada arma todo en orden: primero Arcade.Led.start/0, que devuelve el pid del proceso del LED; luego Arcade.Uart.start/1 con ese pid, que devuelve el pid del puerto; y con el puerto en mano se lanzan Arcade.Potenciometro.start/1 y Arcade.Botones.start/1. El proceso principal queda dormido en un bucle de Process.sleep/1, porque en AtomVM si el proceso inicial termina, el programa termina con él.
El protocolo serial
El protocolo es texto plano, una línea por evento. No hay checksum, no hay handshake, no hay versión. Para una demo eso está bien y tiene una ventaja enorme: puedes abrir un monitor serial y leer lo que pasa con tus propios ojos.
| Dirección | Línea | Significado |
|---|---|---|
| ESP32 → daemon | pot:1830 | Posición del potenciómetro, valor crudo de 0 a 4095 |
| ESP32 → daemon | btn:z:down | Se presionó el botón mapeado a Z |
| ESP32 → daemon | btn:z:up | Se soltó ese botón |
| ESP32 → daemon | btn:x:down / btn:x:up | Ídem para el botón X |
| daemon → ESP32 | red | Destello rojo (choque) |
| daemon → ESP32 | blue | Destello azul (hito de puntaje) |
| daemon → ESP32 | green | Color de reposo |
| daemon → ESP32 | off | Apagar el LED |
El daemon en Go
El daemon vive en el PC y es la bisagra del sistema. Se eligió Go por una razón práctica: existen librerías maduras para inyectar eventos de teclado a nivel de sistema operativo (keybd_event) y para hablar con puertos serie (go-serial), y el binario resultante no tiene dependencias de runtime.
package main
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/micmonay/keybd_event"
"go.bug.st/serial"
)
const (
puertoSerial = "/dev/ttyUSB0"
baudios = 115200
centro = 2048
zonaMuerta = 300
)
type direccion int
const (
centrado direccion = iota
izquierda
derecha
)
func main() {
puerto, err := serial.Open(puertoSerial, &serial.Mode{BaudRate: baudios})
if err != nil {
fmt.Fprintf(os.Stderr, "no se pudo abrir %s: %v\n", puertoSerial, err)
os.Exit(1)
}
defer puerto.Close()
teclado, err := keybd_event.NewKeyBonding()
if err != nil {
fmt.Fprintf(os.Stderr, "teclado: %v\n", err)
os.Exit(1)
}
time.Sleep(2 * time.Second) // uinput necesita registrarse en Linux
go leerSerial(puerto, &teclado) // ESP32 -> teclas del sistema
leerJuego(puerto) // stdout del juego -> colores del LED
}
func leerSerial(puerto serial.Port, teclado *keybd_event.KeyBonding) {
scanner := bufio.NewScanner(puerto)
estado := centrado
for scanner.Scan() {
partes := strings.Split(strings.TrimSpace(scanner.Text()), ":")
switch {
case partes[0] == "pot" && len(partes) == 2:
valor, err := strconv.Atoi(partes[1])
if err != nil {
continue
}
if nuevo := clasificar(valor); nuevo != estado {
aplicarDireccion(teclado, estado, nuevo)
estado = nuevo
}
case partes[0] == "btn" && len(partes) == 3:
tecla := mapaTeclas(partes[1])
if tecla == 0 {
continue
}
teclado.SetKeys(tecla)
if partes[2] == "down" {
teclado.Press()
} else {
teclado.Release()
}
}
}
}
func clasificar(valor int) direccion {
switch {
case valor < centro-zonaMuerta:
return izquierda
case valor > centro+zonaMuerta:
return derecha
default:
return centrado
}
}
func aplicarDireccion(teclado *keybd_event.KeyBonding, anterior, nuevo direccion) {
flecha := map[direccion]int{izquierda: keybd_event.VK_LEFT, derecha: keybd_event.VK_RIGHT}
if tecla, ok := flecha[anterior]; ok {
teclado.SetKeys(tecla)
teclado.Release() // soltar SIEMPRE antes de presionar la nueva
}
if tecla, ok := flecha[nuevo]; ok {
teclado.SetKeys(tecla)
teclado.Press()
}
}
// mapaTeclas devuelve VK_Z para "z", VK_X para "x" y 0 si no reconoce el nombre.
// leerJuego lee stdin linea por linea y, ante "event:explode",
// "event:score_milestone" o "event:restart", escribe respectivamente
// "red\n", "blue\n" o "green\n" en el puerto serial.
La zona muerta merece explicación. Un potenciómetro no vuelve exactamente al centro cuando lo sueltas, y el ADC además tiene ruido; sin zona muerta el auto estaría girando permanentemente hacia un lado. Los ±300 unidades alrededor de 2048 definen una región donde no se presiona ninguna flecha, y el valor exacto se calibra imprimiendo las lecturas crudas con el volante en reposo, observando el rango de oscilación y multiplicándolo por dos. Fíjate también en aplicarDireccion: siempre suelta la tecla anterior antes de presionar la nueva, porque si no lo haces un giro rápido de izquierda a derecha deja la flecha izquierda presionada para siempre y el juego se vuelve injugable. Este tipo de error de estado es la falla número uno en interfaces físicas.
Modificaciones al cartucho de TIC-80
El juego original no emite nada. Hay que instrumentarlo, y TIC-80 ofrece trace(), que escribe en la consola y por lo tanto en stdout del proceso. Dos inserciones bastan:
-- Dentro de la funcion que suma puntaje
function sumar_puntaje(cantidad)
local anterior = puntaje
puntaje = puntaje + cantidad
-- Un evento cada 2500 puntos cruzados
if math.floor(puntaje / 2500) > math.floor(anterior / 2500) then
trace("event:score_milestone")
end
end
-- Dentro de la deteccion de colision del auto
function chocar()
vivo = false
trace("event:explode")
end
Y la ejecución conecta ambos procesos con una tubería del shell: tic80 car_adventure.tic | go run main.go. TIC-80 escribe sus traces en stdout, la tubería los entrega al stdin del daemon, y el daemon los traduce a comandos de color por serial. Todo el acoplamiento entre el juego y el hardware cabe en esa única línea.
El flujo completo, paso a paso
sequenceDiagram
participant J as Jugador
participant E as ESP32 (AtomVM)
participant D as Daemon Go
participant T as TIC-80
J->>E: Gira el volante a la derecha
E->>E: ADC lee 3200 (delta > 50)
E->>D: "pot:3200\n" por UART
D->>D: clasificar(3200) = derecha
D->>T: Suelta LEFT, presiona RIGHT
T->>T: El auto gira
T->>T: Colision detectada
T->>D: trace "event:explode" por stdout
D->>E: "red\n" por UART
E->>E: LEDC set_duty rojo al maximo
E->>J: El LED del gabinete se pone rojo
Note over E,D: Tras 1500 ms el LED vuelve al color previo
La latencia total percibida es la suma de tres términos: hasta 200 ms del período de muestreo del ADC, un par de milisegundos del serial a 115200 baud, y el tiempo que el sistema operativo tarda en propagar el evento de teclado. Los 200 ms del muestreo dominan; si el juego se siente lento, ese es el número a bajar antes que cualquier otro.
Proyecto 2: el Coliseo Atómico
La idea
El Coliseo Atómico es una competencia educativa de robots de combate pensada para armarse con componentes accesibles, sin soldadura y con un presupuesto acotado —el proyecto original apunta a menos de 2 UF, del orden de 65 a 80 dólares por robot. Cada participante construye un robot con chasis, dos motores DC, un servo y un ESP32 corriendo AtomVM, y compite en una arena. La mecánica de “vida” es lo que hace el proyecto seguro y barato al mismo tiempo: en lugar de armas destructivas, cada robot lleva un globo montado sobre la carrocería y una minifigura encima, y el objetivo es reventar el globo del rival o derribar su figura. Nada se rompe, nada corta, y el resultado es inequívoco a simple vista.
Formatos de competencia
| Formato | Arena | Objetivo | Habilidad que exige |
|---|---|---|---|
| Combate de gladiadores | Circular, sin obstáculos | Reventar el globo rival o derribar su figura | Maniobra en espacio cerrado, control fino |
| Carrera de cuadrigas | Pista de tres vueltas con obstáculos | Terminar primero | Velocidad sostenida y trazado |
| Justa | Pista lineal, dos carriles | Derribar la figura rival con una lanza superior | Alineación y control del servo en el instante justo |
| Batalla naval | Superficie con agua | Recolectar pelotas de ping pong del color propio o impactar al rival | Impermeabilización y tracción sobre superficie deslizante |
Cada formato pone a prueba una parte distinta del sistema. La justa, en particular, obliga a pensar el control del servo con precisión: el brazo tiene que subir y bajar en el momento correcto, y eso es un problema de temporización que se resuelve limpio con procesos y mensajes.
Lista de materiales
| Componente | Cantidad | Rol |
|---|---|---|
| ESP32 DevKit V1 (30 pines) | 1 | Cerebro; WiFi y Bluetooth integrados |
| Módulo L298N | 1 | Puente H doble para los motores DC |
| Motor DC 3-9 V (hasta 250 RPM) con caja reductora | 2 | Tracción diferencial |
| Servo SG90 (4.8 V, 1.2 kg·cm) | 1 | Lanza, pala o brazo |
| Chasis con rueda loca omnidireccional | 1 | Estructura |
| Portapilas o batería LiPo/NiMH | 1 | Alimentación de potencia |
| Globos | varios | Mecánica de “vida” |
| Minifigura tipo LEGO | 1 | Objetivo derribable (el “auriga”) |
| Módulo evasor de obstáculos IR | 1 | Detección de bordes o rivales |
| Sensor DHT11 | 1 | Opcional: telemetría del ambiente |
| Pantalla OLED 0.96” | 1 | Opcional: estado y batería |
| Protoboard, resistencias, LEDs | 1 set | Indicadores y prototipado |
La arquitectura eléctrica
Este es el proyecto donde la electricidad importa de verdad. Los motores DC consumen corrientes de arranque varias veces mayores que las de régimen, y esos picos hunden el voltaje del riel; si el ESP32 comparte riel con los motores sin desacoplo, se reinicia en cuanto arrancan.
flowchart TD
BAT["Bateria 7.4 V<br/>(alimentacion de potencia)"] --> L298["Modulo L298N<br/>puente H doble"]
BAT --> REG["Regulador 5 V<br/>del L298N o externo"]
REG --> ESP["ESP32 pin VIN"]
ESP -->|"GPIO ENA - PWM"| L298
ESP -->|"GPIO IN1, IN2"| L298
ESP -->|"GPIO IN3, IN4"| L298
ESP -->|"GPIO ENB - PWM"| L298
L298 --> M1["Motor izquierdo"]
L298 --> M2["Motor derecho"]
REG --> SRV["Servo SG90<br/>senal desde GPIO"]
ESP -.->|"masa comun obligatoria"| L298
ESP -.->|"masa comun obligatoria"| SRV
Reglas que no se negocian en este montaje:
- La masa es común. El GND del ESP32, el del L298N y el del servo tienen que estar unidos. Sin referencia común, las señales de control son ruido.
- El servo no se alimenta desde el pin 3V3 del ESP32. Un SG90 en movimiento pide picos de varios cientos de miliamperios; el regulador de la placa no los entrega. Va al riel de 5 V.
- El puente H nunca recibe IN1 e IN2 en alto al mismo tiempo en configuraciones que no lo soporten: eso es un cortocircuito de rama en algunos drivers. En el L298N esa combinación equivale a freno, pero la costumbre de evitarla te salva con otros chips.
- Un condensador electrolítico de 470 a 1000 µF entre el riel de potencia y masa, cerca del L298N, absorbe los picos de arranque.
Mapa de pines del robot
| GPIO | Señal | Destino |
|---|---|---|
| 13 | ENA (PWM) | Velocidad motor izquierdo |
| 12 | IN1 | Sentido motor izquierdo |
| 14 | IN2 | Sentido motor izquierdo |
| 27 | IN3 | Sentido motor derecho |
| 26 | IN4 | Sentido motor derecho |
| 25 | ENB (PWM) | Velocidad motor derecho |
| 18 | Señal PWM 50 Hz | Servo SG90 |
| 34 | Entrada digital | Módulo evasor de obstáculos |
| 2 | Salida | LED de estado del enlace |
El driver de motores
Un puente H se controla con tres señales por motor: dos digitales que definen el sentido y una PWM que define la velocidad. La abstracción correcta es una función que reciba un valor con signo entre -1000 y 1000 y lo traduzca a ese trío de señales.
defmodule Coliseo.Motores do
@moduledoc """
Control de dos motores DC a traves de un modulo L298N.
La velocidad se expresa como un entero con signo entre -1000 y 1000.
"""
@speed_mode 0
@timer 1
@freq_hz 1000
@resolution 10
@max_duty 1023
# {nombre, canal LEDC, GPIO enable, GPIO IN_A, GPIO IN_B}
@motores [{:izquierdo, 3, 13, 12, 14}, {:derecho, 4, 25, 27, 26}]
def iniciar do
:gpio.open()
:ledc.timer_config(
duty_resolution: @resolution, freq_hz: @freq_hz,
speed_mode: @speed_mode, timer_num: @timer
)
Enum.each(@motores, fn {_n, canal, en, ina, inb} ->
Enum.each([ina, inb], fn pin ->
:gpio.set_pin_mode(pin, :output)
:gpio.digital_write(pin, :low)
end)
:ledc.channel_config(
channel: canal, duty: 0, gpio_num: en,
speed_mode: @speed_mode, hpoint: 0, timer_sel: @timer
)
end)
end
@doc "Positivo avanza, negativo retrocede, cero deja el motor libre."
def mover(nombre, velocidad) do
{_n, canal, _en, ina, inb} = Enum.find(@motores, &(elem(&1, 0) == nombre))
v = velocidad |> max(-1000) |> min(1000)
{a, b} =
cond do
v > 0 -> {:high, :low}
v < 0 -> {:low, :high}
true -> {:low, :low}
end
:gpio.digital_write(ina, a)
:gpio.digital_write(inb, b)
:ledc.set_duty(@speed_mode, canal, div(abs(v) * @max_duty, 1000))
:ledc.update_duty(@speed_mode, canal)
end
def detener do
Enum.each(@motores, fn {nombre, _c, _e, _a, _b} -> mover(nombre, 0) end)
end
@doc "Freno activo: ambas entradas en alto cortocircuitan el motor."
def frenar do
Enum.each(@motores, fn {_n, canal, _en, ina, inb} ->
:gpio.digital_write(ina, :high)
:gpio.digital_write(inb, :high)
:ledc.set_duty(@speed_mode, canal, @max_duty)
:ledc.update_duty(@speed_mode, canal)
end)
end
end
Sobre la frecuencia de PWM de los motores: 1 kHz es un valor de compromiso. Muy por debajo (100 Hz) el motor vibra audiblemente a esa frecuencia; muy por arriba (20 kHz) el puente H disipa más en cada conmutación y algunos L298N pierden par a ciclos de trabajo bajos. Entre 500 Hz y 5 kHz funciona bien con este driver.
Mezcla diferencial: de joystick a motores
El mando entrega dos ejes, avance y giro, y los motores necesitan dos velocidades. La conversión se llama mezcla diferencial y es un par de sumas.
# Dentro del modulo Coliseo.Mezcla
def diferencial(avance, giro) do
izq = avance + giro
der = avance - giro
maximo = max(abs(izq), abs(der))
# Si la suma se sale de rango, escalar ambos por igual
# para conservar la proporcion del giro.
if maximo > 1000 do
{div(izq * 1000, maximo), div(der * 1000, maximo)}
else
{izq, der}
end
end
El escalado proporcional del final es lo que distingue una implementación buena de una ingenua. Si simplemente recortaras cada valor a ±1000, un avance máximo con giro leve saturaría el motor exterior y el robot giraría más de lo pedido; escalando ambos por el mismo factor, la relación entre las ruedas —y por lo tanto el radio de giro— se mantiene.
El servo de la lanza
Un servo de posición se controla con pulsos de ancho variable repetidos a 50 Hz: el ancho codifica el ángulo, aproximadamente 1 ms para 0°, 1.5 ms para 90° y 2 ms para 180°, dentro de un período de 20 ms. Con LEDC a 50 Hz y resolución de 16 bits, el período completo corresponde a 65535 unidades de duty, así que la conversión es una regla de tres: si 20 ms son 65535, entonces 1.5 ms son 65535 × 1.5 / 20 ≈ 4915.
defmodule Coliseo.Servo do
@moduledoc "Controla un servo SG90 con un canal LEDC a 50 Hz."
@speed_mode 0
@timer 2
@canal 5
@gpio 18
@freq_hz 50
@resolution 16
@periodo_us 20_000
@max_duty 65_535
# Anchos de pulso utiles del SG90, en microsegundos.
@pulso_min_us 500
@pulso_max_us 2400
def iniciar do
:ledc.timer_config(
duty_resolution: @resolution, freq_hz: @freq_hz,
speed_mode: @speed_mode, timer_num: @timer
)
:ledc.channel_config(
channel: @canal, duty: 0, gpio_num: @gpio,
speed_mode: @speed_mode, hpoint: 0, timer_sel: @timer
)
angulo(90)
end
def angulo(grados) when grados >= 0 and grados <= 180 do
ancho_us = @pulso_min_us + div((@pulso_max_us - @pulso_min_us) * grados, 180)
:ledc.set_duty(@speed_mode, @canal, div(ancho_us * @max_duty, @periodo_us))
:ledc.update_duty(@speed_mode, @canal)
end
@doc "Gesto de lanza en su propio proceso, para no bloquear los motores."
def lanzar do
spawn(fn ->
angulo(160)
Process.sleep(250)
angulo(60)
Process.sleep(400)
angulo(90)
end)
end
end
lanzar/0 en un proceso aparte es lo que hace que el robot siga respondiendo al mando mientras la lanza se mueve. En un lenguaje sin procesos baratos tendrías que escribir una máquina de estados con temporizadores explícitos para lograr lo mismo; aquí spawn más Process.sleep alcanza y se lee como la secuencia que es.
Control remoto y failsafe
El robot recibe comandos por WiFi. Retomando lo del capítulo 14, el transporte más simple y de menor latencia es UDP: sin conexión, sin retransmisión, sin cabeceras grandes, y perder un paquete de control no importa porque el siguiente llega 50 ms después. Lo que sí importa —y es el punto de seguridad más relevante de todo el proyecto— es qué pasa cuando dejan de llegar paquetes: el mando se apagó, el WiFi cayó, el operador se alejó. Un robot con motores a fondo y sin control es un problema, y la respuesta es un failsafe por watchdog: si no llega ningún comando en un plazo, los motores se detienen solos.
stateDiagram-v2
[*] --> Arrancando
Arrancando --> Esperando: WiFi conectado, socket abierto
Esperando --> Armado: llega el primer comando valido
Armado --> Armado: llega comando (reinicia watchdog)
Armado --> Failsafe: 500 ms sin comandos
Failsafe --> Armado: vuelve a llegar un comando
Failsafe --> Failsafe: sigue sin comandos, motores detenidos
Armado --> Detenido: comando de parada
Detenido --> Armado: comando de rearme
Esperando --> Failsafe: timeout inicial
La implementación aprovecha que receive en Elixir acepta una cláusula after, que es exactamente un watchdog integrado en el lenguaje:
defmodule Coliseo.Control do
@moduledoc """
Recibe comandos UDP del mando y los aplica a los motores.
Si no llega ningun comando dentro del plazo, detiene el robot.
"""
@puerto 5000
@timeout_ms 500
@led_estado 2
def start do
Coliseo.Motores.iniciar()
Coliseo.Servo.iniciar()
:gpio.set_pin_mode(@led_estado, :output)
{:ok, socket} = :gen_udp.open(@puerto, [:binary, active: true])
:io.format(~c"Escuchando comandos UDP en el puerto ~p~n", [@puerto])
loop(socket, :esperando)
end
defp loop(socket, estado) do
receive do
{:udp, ^socket, _ip, _puerto_origen, datos} ->
aplicar(datos)
indicar(:enlace_ok)
loop(socket, :armado)
after
@timeout_ms ->
if estado != :failsafe do
:io.format(~c"Sin comandos por ~p ms: failsafe~n", [@timeout_ms])
end
Coliseo.Motores.detener()
indicar(:sin_enlace)
loop(socket, :failsafe)
end
end
defp aplicar(<<"mv:", resto::binary>>) do
case :binary.split(resto, ",") do
[a, g] ->
avance = a |> String.trim() |> String.to_integer()
giro = g |> String.trim() |> String.to_integer()
{izq, der} = Coliseo.Mezcla.diferencial(avance, giro)
Coliseo.Motores.mover(:izquierdo, izq)
Coliseo.Motores.mover(:derecho, der)
_ ->
:ok
end
end
defp aplicar("lance"), do: Coliseo.Servo.lanzar()
defp aplicar("stop"), do: Coliseo.Motores.frenar()
defp aplicar(<<"servo:", g::binary>>) do
g |> String.trim() |> String.to_integer() |> Coliseo.Servo.angulo()
end
defp aplicar(_desconocido), do: :ok
defp indicar(:enlace_ok), do: :gpio.digital_write(@led_estado, :high)
defp indicar(:sin_enlace), do: :gpio.digital_write(@led_estado, :low)
end
El after @timeout_ms no es un adorno defensivo: es la única línea del programa que garantiza que el robot se detiene si algo falla. En sistemas con actuadores de potencia, el estado seguro tiene que ser el estado por defecto, el que se alcanza cuando nada funciona; aquí eso se cumple porque el timeout dispara sin que nadie tenga que acordarse de llamarlo. El mando del otro lado cabe en diez líneas: abre un socket con :gen_udp.open(0, [:binary]), lee los ejes del control, envía "mv:#{avance},#{giro}" a la IP del robot con :gen_udp.send/4 y duerme 50 ms antes de repetir. Mandar comandos a 20 Hz aunque el joystick no se mueva parece un desperdicio, pero es intencional: cada paquete es también un latido que le dice al robot que el enlace sigue vivo, el mismo principio de los heartbeats de supervisión aplicado sobre la red.
Sobre el presupuesto, la decisión con más impacto en el desempeño real es la batería: un pack de pilas alcalinas AA entrega bien los primeros minutos y luego el voltaje cae, y en una competencia de varias rondas eso se nota como un robot que “se cansa”. Un pack recargable NiMH o una LiPo de dos celdas con protección mantiene el voltaje mucho más plano por unos pocos dólares más.
Proyecto 3: Tagboard
La idea
Tagboard es un tablero de mensajes que corre completo dentro del ESP32. No hay servidor en la nube, no hay base de datos externa, no hay backend en otra máquina. El microcontrolador levanta WiFi, sirve una página web, expone una pequeña API JSON, guarda los mensajes en la memoria no volátil de la placa y los devuelve a quien pregunte.
Es el proyecto que mejor muestra qué significa que AtomVM sea una máquina virtual del BEAM y no solo un intérprete: tienes procesos, tienes term_to_binary para serializar cualquier estructura de Elixir y tienes un servidor HTTP concurrente, todo en un chip de menos de un dólar. El caso de uso típico es un tablero físico en una sala, una oficina o un stand: cualquiera se conecta a la red del dispositivo con su teléfono, escribe un mensaje, y todos los que tengan la página abierta lo ven aparecer en pocos segundos.
Arquitectura
flowchart TD
subgraph ESP["ESP32 con AtomVM"]
NET["network<br/>modo AP o STA"] --> HTTP["http_server<br/>puerto 80"]
HTTP --> ROUTER["Router de rutas"]
ROUTER --> API["Handler de API<br/>crear y listar"]
ROUTER --> STATIC["Handler estatico<br/>index.html y bundle.js"]
API <--> STORE["Proceso Almacen<br/>lista de tags en RAM"]
STORE <-->|"term_to_binary /<br/>binary_to_term"| NVS["NVS (flash)"]
end
STATIC -->|"HTML y JS embebidos<br/>en el .avm"| LIT["Web Components (Lit)<br/>en el navegador"]
LIT -->|"GET /api/tags/get<br/>cada 3 segundos"| API
LIT -->|"POST /api/tags/create"| API
Las decisiones estructurales son tres, y cada una responde a una limitación concreta del hardware:
- El estado vive en un proceso, no en NVS. Leer flash en cada petición es lento y desgasta la memoria. El proceso
Almacenmantiene la lista en RAM y escribe a NVS solo cuando algo cambia. - Hay un límite duro de 25 mensajes. No es arbitrario: cada escritura a NVS tiene un tamaño máximo por entrada y la RAM disponible en AtomVM es del orden de decenas de kilobytes. Con 25 mensajes cortos el consumo es predecible.
- El frontend se empaqueta como un solo archivo. No hay CDN ni múltiples requests:
esbuildjunta todo el JavaScript en unbundle.jsy ese archivo, junto alindex.html, viaja embebido dentro del.avmque se flashea.
La API
| Método | Ruta | Cuerpo de entrada | Respuesta |
|---|---|---|---|
GET | / | — | index.html |
GET | /bundle.js | — | JavaScript del frontend |
POST | /api/tags/create | {"author": "...", "content": "..."} | {"ok": true} |
GET | /api/tags/get | — | {"tags": [{author, content, timestamp}]} |
Solo dos rutas de API: cada ruta adicional es código en flash, y en un dispositivo con recursos contados el diseño mínimo es el diseño correcto.
El almacén con persistencia en NVS
term_to_binary/1 y binary_to_term/1 son la razón por la que este proyecto es corto: en lugar de definir un formato de archivo, un parser y un serializador, se guarda la estructura de datos de Elixir tal cual y se recupera idéntica.
defmodule Tagboard.Almacen do
@moduledoc "Mantiene los tags en memoria y los persiste en NVS, con tope de @limite."
@namespace "tagboard"
@clave "tags"
@limite 25
def start do
pid = spawn(fn -> loop(cargar()) end)
:erlang.register(:almacen, pid)
pid
end
def crear(autor, contenido), do: send(:almacen, {:crear, autor, contenido})
def listar do
send(:almacen, {:listar, self()})
receive do
{:tags, tags} -> tags
after
1000 -> []
end
end
defp loop(tags) do
receive do
{:crear, autor, contenido} ->
nuevo = %{
author: sanear(autor, 24),
content: sanear(contenido, 140),
timestamp: :erlang.system_time(:second)
}
actualizados = Enum.take([nuevo | tags], @limite)
guardar(actualizados)
loop(actualizados)
{:listar, remitente} ->
send(remitente, {:tags, tags})
loop(tags)
end
end
defp cargar do
case :esp.nvs_get_binary(@namespace, @clave) do
binario when is_binary(binario) ->
try do
:erlang.binary_to_term(binario)
rescue
_ -> []
end
_ ->
[]
end
end
defp guardar(tags) do
:esp.nvs_set_binary(@namespace, @clave, :erlang.term_to_binary(tags))
end
defp sanear(texto, largo) when is_binary(texto) do
texto |> String.trim() |> String.slice(0, largo)
end
defp sanear(_otro, _largo), do: ""
end
Tres puntos de diseño. El try/rescue alrededor de binary_to_term protege un caso real: si flasheas una versión que guardaba otra estructura, el binario viejo sigue en NVS y deserializarlo puede fallar, y sin esa protección el dispositivo entra en ciclo de reinicio sin que puedas siquiera conectarte a arreglarlo. Enum.take/2 sobre la lista con el mensaje nuevo al frente implementa el límite y el orden “más reciente primero” en una sola operación. Y el saneamiento del texto recorta el largo antes de guardar: sin ese recorte, un mensaje de 10 kB llenaría la entrada de NVS y las escrituras siguientes fallarían.
Sobre los nombres exactos de las funciones de NVS: AtomVM ha expuesto variantes como nvs_set_binary/3, nvs_get_binary/2 y nvs_get_binary/3 con valor por defecto, además de nvs_fetch_binary/2 que devuelve {:ok, valor} o :error. Verifica cuál está disponible en el firmware que flasheaste antes de dar por hecho una firma; el concepto —espacio de nombres, clave y binario— es el mismo en todas.
El servidor HTTP
AtomVM incluye un módulo http_server en eavmlib. El patrón de uso es levantar el servidor en un puerto y entregarle una tabla de rutas donde cada entrada asocia un patrón de ruta con un manejador.
defmodule Tagboard.Server do
@moduledoc "Levanta el servidor HTTP y despacha las rutas del tablero."
@puerto 80
def start do
rutas = [
{"/api/tags/create", Tagboard.Handlers.Crear},
{"/api/tags/get", Tagboard.Handlers.Listar},
{"/bundle.js", Tagboard.Handlers.Bundle},
{"/", Tagboard.Handlers.Index}
]
:http_server.start_server(@puerto, rutas)
:io.format(~c"Tagboard escuchando en el puerto ~p~n", [@puerto])
end
end
Los manejadores implementan el comportamiento que espera http_server: una función que recibe el método, el camino y la petición, y devuelve el estado, las cabeceras y el cuerpo.
defmodule Tagboard.Handlers.Listar do
@moduledoc "GET /api/tags/get - devuelve todos los tags en JSON."
def handle_req(:GET, _camino, _peticion) do
cuerpo = Tagboard.Json.tags_a_json(Tagboard.Almacen.listar())
{200, [{"Content-Type", "application/json"}, {"Cache-Control", "no-store"}], cuerpo}
end
end
defmodule Tagboard.Handlers.Crear do```elixir
defmodule Tagboard.Handlers.Crear do
@moduledoc "POST /api/tags/create - agrega un tag al tablero."
def handle_req(:POST, _camino, peticion) do
with {:ok, cuerpo} <- extraer_cuerpo(peticion),
{:ok, autor, contenido} <- Tagboard.Json.parsear_tag(cuerpo) do
Tagboard.Almacen.crear(autor, contenido)
{200, [{"Content-Type", "application/json"}], ~s({"ok":true})}
else
_ ->
{400, [{"Content-Type", "application/json"}],
~s({"ok":false,"error":"peticion invalida"})}
end
end
def handle_req(_metodo, _camino, _peticion) do
{405, [{"Content-Type", "text/plain"}], "Metodo no permitido"}
end
# La representacion de la peticion cambia entre versiones de http_server:
# unas exponen el cuerpo como campo :body y otras como clave "body".
defp extraer_cuerpo(%{body: c}) when is_binary(c), do: {:ok, c}
defp extraer_cuerpo(p) when is_map(p), do: Map.fetch(p, "body")
defp extraer_cuerpo(_), do: :error
end
extraer_cuerpo/1 acepta dos formas porque la representación de la petición varía entre versiones del módulo http_server; aislar esa diferencia en una función pequeña te permite adaptar el proyecto tocando una sola línea. El manejo de JSON, por su parte, es minimalista: eavmlib trae json_encoder para codificar, y para decodificar cuerpos tan simples como este basta un módulo propio de veinte líneas que arma la cadena a mano escapando comillas y usa :binary.split/2 buscando la marca "author":" para quedarse con lo que hay hasta la siguiente comilla. Ese parser es intencionalmente ingenuo: funciona porque el frontend es propio y sabemos exactamente qué envía. En un servicio expuesto a Internet no sería aceptable, y conviene dejarlo explícito en el código para que nadie lo reutilice fuera de lugar.
El frontend
El frontend usa Web Components con la librería Lit, una elección coherente con el resto: sin framework pesado, sin build complejo, un solo archivo resultante.
import { LitElement, html } from "lit";
class TagBoard extends LitElement {
static properties = { tags: { state: true }, autor: {}, contenido: {} };
constructor() {
super();
this.tags = [];
this.autor = "";
this.contenido = "";
}
connectedCallback() {
super.connectedCallback();
this.sincronizar();
this.temporizador = setInterval(() => this.sincronizar(), 3000);
}
disconnectedCallback() {
clearInterval(this.temporizador);
super.disconnectedCallback();
}
async sincronizar() {
try {
const respuesta = await fetch("/api/tags/get");
if (!respuesta.ok) return;
this.tags = (await respuesta.json()).tags || [];
} catch (error) {
// El ESP32 puede tardar bajo carga: reintentamos en el siguiente ciclo.
console.warn("sincronizacion fallida", error);
}
}
async enviar(evento) {
evento.preventDefault();
if (!this.contenido.trim()) return;
await fetch("/api/tags/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
author: this.autor || "anonimo",
content: this.contenido,
}),
});
this.contenido = "";
await this.sincronizar();
}
render() {
return html`
<form @submit=${this.enviar}>
<input .value=${this.autor} placeholder="Tu nombre"
@input=${(e) => (this.autor = e.target.value)} />
<textarea .value=${this.contenido} maxlength="140" placeholder="Mensaje"
@input=${(e) => (this.contenido = e.target.value)}></textarea>
<button type="submit">Publicar</button>
</form>
${this.tags.map(
(t) => html`<div class="tag">
<strong>${t.author}</strong>
<p>${t.content}</p>
<small>${new Date(t.timestamp * 1000).toLocaleString()}</small>
</div>`
)}
`;
}
}
customElements.define("tag-board", TagBoard);
El index.html que lo carga es mínimo: las metaetiquetas de charset y viewport, un <h1>, la etiqueta <tag-board></tag-board> y un <script src="/bundle.js"></script> al final del body.
El empaquetado con esbuild produce el archivo único que el ESP32 va a servir:
npx esbuild src/tagboard.js \
--bundle \
--minify \
--format=iife \
--outfile=priv/bundle.js
Tres detalles del build importan aquí. --bundle resuelve el import de Lit y lo incluye; sin eso el navegador pediría lit a un servidor que no existe. --minify no es cosmético: cada kilobyte ahorrado es flash que no gastas y tiempo de transferencia que el ESP32 no tiene que sostener. Y --format=iife genera un script clásico que funciona con una etiqueta <script> simple, sin type="module", evitando una petición adicional y problemas de MIME.
Sincronización por sondeo
El frontend consulta la API cada 3 segundos. Es sondeo puro, no WebSockets ni Server-Sent Events, y hay una razón: cada conexión abierta consume memoria en el ESP32, y con varios visitantes simultáneos las conexiones persistentes agotan los recursos antes que las peticiones cortas y espaciadas. El retardo máximo entre publicar y ver es el intervalo de sondeo; tres segundos se sienten inmediatos para un tablero de mensajes y mantienen la carga en el orden de una petición por visitante cada tres segundos, que un ESP32 sostiene con una decena de personas conectadas.
Modo AP contra modo estación
El Tagboard puede levantarse de dos formas, y la elección cambia por completo la experiencia:
| Aspecto | Modo AP (punto de acceso) | Modo STA (estación) |
|---|---|---|
| El ESP32 | Crea su propia red WiFi | Se une a una red existente |
| Los visitantes | Se conectan a la red del dispositivo | Usan la red del lugar |
| Dirección | Fija, típicamente 192.168.4.1 | Asignada por DHCP, hay que descubrirla |
| Internet | No hay (el teléfono lo advierte) | Sí, si la red lo tiene |
| Ideal para | Ferias, stands, lugares sin red confiable | Instalación fija en una oficina |
Levantar el modo AP se hace con :network.start/1 pasando una lista con la clave ap:, que incluye ssid, psk y callbacks como ap_started y sta_connected. La contraseña debe tener al menos 8 caracteres para WPA2; un AP abierto sirve para una feria, pero implica que cualquiera en el rango puede publicar, y para eso está el límite de 25 mensajes. En ambos modos conviene sumar mDNS para que el tablero responda a un nombre legible en lugar de una IP, y en modo STA es prácticamente obligatorio porque la dirección cambia con cada arranque.
Los patrones que se repiten
Habiendo desarmado los tres proyectos, conviene nombrar lo que tienen en común: estos son los patrones reutilizables en cualquier proyecto AtomVM propio.
Un proceso por responsabilidad. En la arcade hay un proceso para el LED, uno para el ADC, uno para los botones y uno para el UART. En el Coliseo, el gesto de la lanza corre aparte del control de motores. En el Tagboard, el almacén es independiente del servidor. Ninguno de estos programas tiene un bucle principal gigante con un case de veinte ramas, y eso no es estilo: es lo que permite que una parte se trabe sin llevarse el resto.
Protocolos legibles. El serial de la arcade manda pot:1830, el mando del Coliseo manda mv:400,-120, el Tagboard habla JSON. Los tres se pueden inspeccionar con un monitor serial o con curl. Cuando algo falla en una demo con público mirando, ver el tráfico en texto plano es la diferencia entre arreglarlo en un minuto o no arreglarlo.
El estado seguro es el estado por defecto. El after del receive en el Coliseo detiene los motores cuando nada llega. El try/rescue del Tagboard devuelve una lista vacía cuando NVS trae basura. En ambos casos, el camino de la falla lleva a un lugar inofensivo sin que nadie tenga que acordarse de manejarla.
Filtrar la entrada física siempre. Debounce en los botones, umbral de cambio en el ADC, zona muerta en el potenciómetro. El mundo físico es ruidoso y el software absorbe ese ruido en el borde, lo más cerca posible del sensor, para que las capas de arriba trabajen con datos limpios.
RAM para trabajar, flash para sobrevivir. El Tagboard mantiene la lista en el proceso y escribe a NVS solo al modificarla: leer flash es barato, escribirla es lento y la desgasta, porque tiene un número finito de ciclos de borrado por sector.
Lo pesado, afuera. El bundle de JavaScript se arma con esbuild en tu máquina, no en el chip; la emulación de teclado y el juego corren en el PC. El microcontrolador hace aquello para lo que es bueno —tocar el hardware, mantener estado pequeño, responder rápido— y delega el resto.
Errores comunes
| Error | Síntoma | Causa | Solución |
|---|---|---|---|
| ADC devuelve siempre 0 o valores erráticos | El volante no responde | Se usó un pin de ADC2 con WiFi activo | Migrar el potenciómetro a un pin de ADC1 (GPIO 32 a 39) |
| El auto gira solo en el juego | Sin tocar el volante, el vehículo se va a un lado | Falta zona muerta alrededor del centro | Definir un rango de tolerancia y no emitir tecla dentro de él |
| Una tecla queda presionada para siempre | El juego se vuelve incontrolable | El daemon presionó la nueva flecha sin soltar la anterior | Soltar siempre el estado anterior antes de aplicar el nuevo |
| Un apretón cuenta como varios | Se dispara cinco veces con una pulsación | Rebote mecánico del pulsador sin filtrar | Debounce por tiempo de estabilidad de 50 ms o más |
| Líneas seriales cortadas por la mitad | Comandos ignorados o mal interpretados | Se asumió que cada mensaje UART es una línea completa | Acumular en búfer y separar por el delimitador \n |
| El ESP32 se reinicia al arrancar los motores | Reinicios repetidos bajo carga | Caída de tensión por el pico de corriente de arranque | Fuente separada o condensador de 470 a 1000 µF en el riel de potencia |
| El servo tiembla o no llega al ángulo | Movimiento errático o incompleto | Alimentado desde 3V3 del ESP32 o frecuencia PWM incorrecta | Alimentar desde 5 V y configurar LEDC a exactamente 50 Hz |
| El robot sigue andando con el mando apagado | Pérdida total de control | No hay watchdog en el bucle de recepción | Cláusula after en el receive que detiene motores |
| El robot gira más de lo pedido a velocidad alta | El giro se exagera cuando avanza rápido | Recorte independiente por motor en vez de escalado proporcional | Escalar ambas velocidades por el mismo factor |
| Los mensajes del tablero desaparecen al reiniciar | El tablero arranca vacío | Se guardó solo en el proceso, sin escribir a NVS | Persistir con nvs_set_binary en cada modificación |
| El dispositivo entra en ciclo de reinicio tras actualizar | No termina de arrancar | binary_to_term falla con datos de una versión previa | Envolver la deserialización en try/rescue y borrar la clave si falla |
| El frontend no carga estilos ni componentes | Página en blanco o sin formato | El bundle no incluye Lit o el MIME es incorrecto | Empaquetar con --bundle y servir con Content-Type: application/javascript |
| El navegador advierte “sin Internet” en modo AP | Los visitantes desconfían y se desconectan | Es el comportamiento normal de un AP sin salida | Explicarlo en el cartel; opcionalmente implementar un portal cautivo |
| Escrituras a NVS fallan tras un tiempo | Los mensajes nuevos no se guardan | La entrada supera el tamaño máximo o la partición se llenó | Recortar el largo de cada mensaje y respetar el límite de 25 |
Ejercicios propuestos
-
Calibración automática del volante. Modifica el firmware de la arcade para que, al arrancar, entre en un modo de calibración de cinco segundos: pide girar el potenciómetro de extremo a extremo, registra el mínimo y el máximo reales, guarda ambos en NVS y usa ese rango para normalizar las lecturas posteriores a un valor de -1000 a 1000. Verifica que la calibración sobreviva a un reinicio.
-
Protocolo binario con verificación. Reemplaza el protocolo de texto de la arcade por uno binario: un byte de inicio, un byte de tipo, dos bytes de valor y un byte de checksum XOR. Implementa el emisor en Elixir y el receptor en Go. Mide con el monitor serial cuánto se reduce el tráfico y decide si valió la pena.
-
Aceleración progresiva en el Coliseo. Los motores pasan de 0 al valor pedido de golpe, lo que hace derrapar las ruedas. Agrega un proceso que interpole la velocidad hacia el objetivo en escalones de 50 unidades cada 20 ms, y compara el comportamiento del robot al arrancar y al frenar.
-
Telemetría del robot. Usa el DHT11 y un divisor resistivo sobre la batería para que el robot envíe por UDP, una vez por segundo, un paquete con temperatura, humedad y voltaje. Escribe un receptor en Elixir que imprima esos datos y encienda una alerta cuando el voltaje baje de un umbral.
-
Moderación en el Tagboard. Agrega un endpoint
POST /api/tags/deleteprotegido por una clave que llegue en una cabecera, y un botón de borrado visible solo cuando esa clave está presente en el navegador. Piensa qué pasa si alguien intenta borrar mientras otro está publicando, y verifica que el procesoAlmacenserializa ambas operaciones sin condiciones de carrera. -
Sondeo adaptativo. Modifica el frontend para que, si en las últimas cinco consultas no hubo cambios, el intervalo de sondeo suba a 10 segundos, y vuelva a 3 en cuanto detecte un mensaje nuevo. Mide la reducción de peticiones con la pestaña de red del navegador.
-
Fusión de proyectos. Combina el Tagboard con el Coliseo: que el robot exponga un servidor HTTP con una página de control y un registro de los últimos 25 comandos recibidos, persistido en NVS. Decide si el failsafe debe seguir siendo por UDP o si conviene un latido HTTP, y justifica la elección midiendo la latencia de ambos.
Lo que viene
Los tres proyectos de este capítulo comparten un límite: viven dentro de las restricciones de AtomVM sobre un microcontrolador. Kilobytes de RAM, sin sistema de archivos completo, sin OTP completo, con un subconjunto de la biblioteca estándar. Ese límite es lo que los hace baratos, instantáneos al arrancar y capaces de correr con una batería pequeña.
En el capítulo 16 cruzamos al otro lado del espectro: Nerves y el BEAM completo corriendo sobre Linux embebido, con Elixir Circuits para hablar con GPIO, I2C y SPI desde una Raspberry Pi, actualizaciones por aire, y hardware pensado desde el diseño para el BEAM como GRiSP y los proyectos de Soleil y BEAM Bots. Vas a ver los mismos problemas —sensar, actuar, comunicar— resueltos con OTP completo, supervisores de verdad y un sistema de archivos, y vas a poder decidir con criterio propio cuándo un proyecto pide un ESP32 de tres dólares con AtomVM y cuándo pide una placa con Linux y Nerves.
El índice completo del curso está en /tecnologias/elixir-robotics/00-indice/.