Tres proyectos AtomVM de punta a punta: máquina arcade, Coliseo Atómico y Tagboard

Por: Artiko
elixirroboticaiotelectronicaatomvmesp32proyectostic80http-servernvs

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ónMáquina arcadeColiseo AtómicoTagboard
Problema centralTraducir entrada física a eventos de tecladoMover un robot de combate y sobrevivir al impactoServir una app web desde el microcontrolador
Rol del ESP32Sensor + actuador de feedbackControlador de potencia y locomociónServidor HTTP con almacenamiento
SensoresPotenciómetro (ADC), 2 botones (GPIO)Módulo de obstáculos, DHT11 opcionalNinguno
ActuadoresLED RGB por PWM2 motores DC, servo SG90, LEDsNinguno (salida por HTTP)
ComunicaciónUART serial hacia el PCWiFi o Bluetooth hacia el mandoWiFi, HTTP hacia el navegador
Software fuera del ESP32Daemon en Go + TIC-80App o mando remotoFrontend con Web Components
PersistenciaNingunaNinguna (o calibración en NVS)NVS con term_to_binary
Dificultad eléctricaBajaAlta (puente H, corrientes de motor)Nula
Dificultad de softwareMedia (protocolo y sincronía)Media (control y failsafe)Alta (HTTP, estado, frontend)
Uso típicoDemo de feria, stand interactivoCompetencia educativaCartelera 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

ComponenteCantidadFunción en el proyecto
ESP32 DevKit (30 pines)1Corre AtomVM, lee sensores y maneja el LED
Potenciómetro 500 kΩ1Hace de volante; se lee por ADC
LED RGB 5 mm cátodo común1Feedback visual del estado del juego
Botón táctil (push button)2Equivalen a las teclas Z y X
Resistencia 220 Ω3Limitan corriente en cada canal del LED
Protoboard y jumpers1 setMontaje sin soldadura
Cable micro USB1Alimentación y puerto serial

El mapa de pines usado en el proyecto original:

GPIODirecciónConectado aNotas
32Entrada analógicaTerminal central del potenciómetroADC1, seguro de usar con WiFi
26Entrada digitalBotón 1 (tecla Z)Con resistencia pull-up interna
25Entrada digitalBotón 2 (tecla X)Con resistencia pull-up interna
22Salida PWMCanal rojo del LEDA través de 220 Ω
21Salida PWMCanal verde del LEDA través de 220 Ω
23Salida PWMCanal azul del LEDA través de 220 Ω
3V3AlimentaciónExtremo del potenciómetroNunca 5 V: el ADC se daña
GNDComúnCátodo del LED, botones, potenciómetroMasa 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ónLíneaSignificado
ESP32 → daemonpot:1830Posición del potenciómetro, valor crudo de 0 a 4095
ESP32 → daemonbtn:z:downSe presionó el botón mapeado a Z
ESP32 → daemonbtn:z:upSe soltó ese botón
ESP32 → daemonbtn:x:down / btn:x:upÍdem para el botón X
daemon → ESP32redDestello rojo (choque)
daemon → ESP32blueDestello azul (hito de puntaje)
daemon → ESP32greenColor de reposo
daemon → ESP32offApagar 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

FormatoArenaObjetivoHabilidad que exige
Combate de gladiadoresCircular, sin obstáculosReventar el globo rival o derribar su figuraManiobra en espacio cerrado, control fino
Carrera de cuadrigasPista de tres vueltas con obstáculosTerminar primeroVelocidad sostenida y trazado
JustaPista lineal, dos carrilesDerribar la figura rival con una lanza superiorAlineación y control del servo en el instante justo
Batalla navalSuperficie con aguaRecolectar pelotas de ping pong del color propio o impactar al rivalImpermeabilizació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

ComponenteCantidadRol
ESP32 DevKit V1 (30 pines)1Cerebro; WiFi y Bluetooth integrados
Módulo L298N1Puente H doble para los motores DC
Motor DC 3-9 V (hasta 250 RPM) con caja reductora2Tracción diferencial
Servo SG90 (4.8 V, 1.2 kg·cm)1Lanza, pala o brazo
Chasis con rueda loca omnidireccional1Estructura
Portapilas o batería LiPo/NiMH1Alimentación de potencia
GlobosvariosMecánica de “vida”
Minifigura tipo LEGO1Objetivo derribable (el “auriga”)
Módulo evasor de obstáculos IR1Detección de bordes o rivales
Sensor DHT111Opcional: telemetría del ambiente
Pantalla OLED 0.96”1Opcional: estado y batería
Protoboard, resistencias, LEDs1 setIndicadores 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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

GPIOSeñalDestino
13ENA (PWM)Velocidad motor izquierdo
12IN1Sentido motor izquierdo
14IN2Sentido motor izquierdo
27IN3Sentido motor derecho
26IN4Sentido motor derecho
25ENB (PWM)Velocidad motor derecho
18Señal PWM 50 HzServo SG90
34Entrada digitalMódulo evasor de obstáculos
2SalidaLED 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:

  1. El estado vive en un proceso, no en NVS. Leer flash en cada petición es lento y desgasta la memoria. El proceso Almacen mantiene la lista en RAM y escribe a NVS solo cuando algo cambia.
  2. 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.
  3. El frontend se empaqueta como un solo archivo. No hay CDN ni múltiples requests: esbuild junta todo el JavaScript en un bundle.js y ese archivo, junto al index.html, viaja embebido dentro del .avm que se flashea.

La API

MétodoRutaCuerpo de entradaRespuesta
GET/index.html
GET/bundle.jsJavaScript 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:

AspectoModo AP (punto de acceso)Modo STA (estación)
El ESP32Crea su propia red WiFiSe une a una red existente
Los visitantesSe conectan a la red del dispositivoUsan la red del lugar
DirecciónFija, típicamente 192.168.4.1Asignada por DHCP, hay que descubrirla
InternetNo hay (el teléfono lo advierte)Sí, si la red lo tiene
Ideal paraFerias, stands, lugares sin red confiableInstalació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

ErrorSíntomaCausaSolución
ADC devuelve siempre 0 o valores erráticosEl volante no respondeSe usó un pin de ADC2 con WiFi activoMigrar el potenciómetro a un pin de ADC1 (GPIO 32 a 39)
El auto gira solo en el juegoSin tocar el volante, el vehículo se va a un ladoFalta zona muerta alrededor del centroDefinir un rango de tolerancia y no emitir tecla dentro de él
Una tecla queda presionada para siempreEl juego se vuelve incontrolableEl daemon presionó la nueva flecha sin soltar la anteriorSoltar siempre el estado anterior antes de aplicar el nuevo
Un apretón cuenta como variosSe dispara cinco veces con una pulsaciónRebote mecánico del pulsador sin filtrarDebounce por tiempo de estabilidad de 50 ms o más
Líneas seriales cortadas por la mitadComandos ignorados o mal interpretadosSe asumió que cada mensaje UART es una línea completaAcumular en búfer y separar por el delimitador \n
El ESP32 se reinicia al arrancar los motoresReinicios repetidos bajo cargaCaída de tensión por el pico de corriente de arranqueFuente separada o condensador de 470 a 1000 µF en el riel de potencia
El servo tiembla o no llega al ánguloMovimiento errático o incompletoAlimentado desde 3V3 del ESP32 o frecuencia PWM incorrectaAlimentar desde 5 V y configurar LEDC a exactamente 50 Hz
El robot sigue andando con el mando apagadoPérdida total de controlNo hay watchdog en el bucle de recepciónCláusula after en el receive que detiene motores
El robot gira más de lo pedido a velocidad altaEl giro se exagera cuando avanza rápidoRecorte independiente por motor en vez de escalado proporcionalEscalar ambas velocidades por el mismo factor
Los mensajes del tablero desaparecen al reiniciarEl tablero arranca vacíoSe guardó solo en el proceso, sin escribir a NVSPersistir con nvs_set_binary en cada modificación
El dispositivo entra en ciclo de reinicio tras actualizarNo termina de arrancarbinary_to_term falla con datos de una versión previaEnvolver la deserialización en try/rescue y borrar la clave si falla
El frontend no carga estilos ni componentesPágina en blanco o sin formatoEl bundle no incluye Lit o el MIME es incorrectoEmpaquetar con --bundle y servir con Content-Type: application/javascript
El navegador advierte “sin Internet” en modo APLos visitantes desconfían y se desconectanEs el comportamiento normal de un AP sin salidaExplicarlo en el cartel; opcionalmente implementar un portal cautivo
Escrituras a NVS fallan tras un tiempoLos mensajes nuevos no se guardanLa 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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. Moderación en el Tagboard. Agrega un endpoint POST /api/tags/delete protegido 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 proceso Almacen serializa ambas operaciones sin condiciones de carrera.

  6. 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.

  7. 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/.