Extender AtomVM: NIFs, ports, Popcorn y control remoto

Por: Artiko
elixirroboticaiotelectronicaatomvmnifpopcornwebassemblyesp32wifi

Extender AtomVM: NIFs, ports, Popcorn y control remoto

En el capitulo 13 montaste el entorno completo: grabaste el firmware de AtomVM en el ESP32, creaste un proyecto con ExAtomVM, empaquetaste tu aplicacion con mix atomvm.packbeam y viste tu primer proceso Elixir corriendo sobre un microcontrolador. Trabajaste con lo que la maquina virtual ya trae: gpio, timer, procesos, mensajes.

Tarde o temprano llegas al borde. Necesitas un periferico que AtomVM todavia no expone. Necesitas un lazo de control que en Elixir puro no alcanza la latencia. Necesitas que el mismo codigo que corre en el chip corra tambien en un navegador para hacer una simulacion. O simplemente necesitas mover el robot desde el telefono sin cable y sin router.

Este capitulo cubre las cuatro salidas de ese borde: los NIFs, funciones escritas en C que se invocan como si fueran funciones Elixir; los port drivers, procesos nativos con estado propio, buzon de mensajes y capacidad de enviar eventos por su cuenta; Popcorn, el mismo AtomVM compilado a WebAssembly ejecutando Elixir dentro del navegador con interoperabilidad con JavaScript; y el control remoto, con el ESP32 levantando su propio punto de acceso WiFi y sirviendo una interfaz web que envia comandos al robot.

Los cuatro comparten una idea: AtomVM no es una caja cerrada, sino una maquina virtual pequena cuyo contrato con el mundo nativo esta documentado y es extensible.

El mapa: donde encaja cada extension

Tu aplicacion Elixir se compila a bytecode BEAM, se empaqueta en un archivo .avm y la maquina virtual lo interpreta. Cuando el bytecode llama a algo que no esta implementado en Elixir, AtomVM busca ese algo en su registro de NIFs o en su registro de port drivers, ambos escritos en C y enlazados dentro del firmware.

flowchart TD
    A["Codigo Elixir<br/>lib/*.ex"] -->|"mix atomvm.packbeam"| B["Bundle .avm<br/>bytecode BEAM"]
    B --> C["AtomVM<br/>interprete de opcodes"]
    C -->|"llamada a funcion<br/>no encontrada en el modulo"| D{"Registro de<br/>extensiones"}
    D -->|"nombre coincide<br/>Modulo:fun/aridad"| E["NIF en C<br/>sincronico, sin estado"]
    D -->|"open_port spawn nombre"| F["Port driver en C<br/>proceso nativo con estado"]
    E --> G["ESP-IDF / HAL<br/>driver del fabricante"]
    F --> G
    G --> H["Silicio<br/>GPIO, SPI, I2C, PWM, radio"]
    C -.->|"procesos, mensajes,<br/>supervision"| I["Runtime Elixir<br/>en el chip"]

La diferencia importante: el NIF vive dentro del proceso que lo llama, mientras que el port driver es un proceso aparte. Esa sola distincion decide casi todo lo demas.

NIF: una funcion Elixir con cuerpo en C

Un NIF (Native Implemented Function) es una funcion C con una firma fija que AtomVM ejecuta cuando el bytecode intenta llamar a una funcion Elixir cuyo nombre coincide con el registrado.

La firma es siempre esta:

static term nif_mi_funcion(Context *ctx, int argc, term argv[]);
  • ctx es el contexto del proceso Elixir que hizo la llamada: contiene su heap, su buzon y un puntero al GlobalContext de toda la VM.
  • argc es la cantidad de argumentos.
  • argv es el arreglo de argumentos, cada uno un term: la representacion interna de un valor Erlang/Elixir.
  • El retorno es un term: lo que la funcion Elixir devuelve.

El detalle clave es que AtomVM resuelve los NIFs por nombre textual, con el formato "Modulo:funcion/aridad". Para un modulo Elixir el nombre incluye el prefijo Elixir., porque asi es como el compilador de Elixir nombra los atomos de modulo. Es decir, SampleApp.Hello.hello/0 se registra como "Elixir.SampleApp.Hello:hello/0".

Estructura minima de un componente NIF

Un NIF para el puerto ESP32 se entrega como un componente de ESP-IDF que se coloca dentro del arbol de fuentes de AtomVM. La estructura minima es:

mi_nif/
  CMakeLists.txt
  Kconfig
  nifs/
    sample_app_hello.c
    include/sample_app_hello.h

Ese directorio se copia (o se enlaza simbolicamente) dentro de AtomVM/src/platforms/esp32/components/. A partir de ahi, ESP-IDF lo compila junto al resto del firmware.

El archivo de cabecera

Lo unico que expone es la funcion resolvedora: dado un nombre de NIF en texto, devuelve el descriptor o NULL.

#ifndef __SAMPLE_APP_HELLO_H__
#define __SAMPLE_APP_HELLO_H__

#include <nifs.h>

#ifdef __cplusplus
extern "C" {
#endif

const struct Nif *sample_app_hello_get_nif(const char *nifname);

#ifdef __cplusplus
}
#endif

#endif

La implementacion

#include "sample_app_hello.h"

#include <context.h>
#include <nifs.h>
#include <portnifloader.h>
#include <term.h>

#include <string.h>

// #define ENABLE_TRACE
#include <trace.h>

// Implementacion: Elixir.SampleApp.Hello:hello/0
static term hello_0(Context *ctx, int argc, term argv[])
{
    (void) ctx;
    (void) argc;
    (void) argv;

    return term_from_int(1234);
}

static const struct Nif hello_0_nif = {
    .base.type = NIFFunctionType,
    .nif_ptr = hello_0
};

const struct Nif *sample_app_hello_get_nif(const char *nifname)
{
    TRACE("Buscando NIF %s ...\n", nifname);

    if (strcmp("Elixir.SampleApp.Hello:hello/0", nifname) == 0) {
        TRACE("NIF resuelto: %s\n", nifname);
        return &hello_0_nif;
    }

    return NULL;
}

// Se ejecutan al arrancar y al detener la VM. Aqui no hacen nada.
static void sample_app_hello_init(GlobalContext *global) { (void) global; }
static void sample_app_hello_destroy(GlobalContext *global) { (void) global; }

#ifdef CONFIG_AVM_SAMPLE_APP_HELLO_NIF_ENABLE
REGISTER_NIF_COLLECTION(
    sample_app_hello,
    sample_app_hello_init,
    sample_app_hello_destroy,
    sample_app_hello_get_nif)
#endif

Tres piezas merecen explicacion.

struct Nif. Es el descriptor. .base.type = NIFFunctionType le dice a la VM que este objeto se comporta como una funcion nativa, y .nif_ptr es el puntero a tu implementacion. Se declara static const para que quede en flash y no consuma RAM.

La funcion resolvedora. AtomVM no mantiene una tabla hash de NIFs de terceros: recorre las colecciones registradas y llama a la funcion resolvedora de cada una con el nombre buscado. Si devuelves NULL, sigue con la siguiente. Por eso una coleccion puede exponer muchos NIFs con una cadena de strcmp o con una tabla propia.

REGISTER_NIF_COLLECTION. Es una macro que expande a tres cosas: un struct NifCollectionDef con tus tres callbacks, un item de lista enlazada, y una funcion marcada con __attribute__((constructor)) que inserta ese item en la lista global antes de que arranque main. Ese truco de constructor es lo que permite registrar extensiones sin tocar ni una linea del codigo de AtomVM.

Los callbacks init y destroy reciben el GlobalContext y se llaman al arrancar y al detener la VM. Son el lugar para reservar recursos globales (registrar tipos de recurso, inicializar un driver del fabricante, crear una cola de FreeRTOS). Si no necesitas ninguno, pasa NULL en su lugar.

El CMakeLists.txt y por que necesita --whole-archive

idf_component_register(
    SRCS "nifs/sample_app_hello.c"
    INCLUDE_DIRS "nifs/include"
    PRIV_REQUIRES "libatomvm" "avm_sys"
)

idf_build_set_property(
    LINK_OPTIONS "-Wl,--whole-archive;${CMAKE_CURRENT_BINARY_DIR}/lib${COMPONENT_NAME}.a;-Wl,--no-whole-archive"
    APPEND
)

PRIV_REQUIRES "libatomvm" "avm_sys" es lo que hace visibles los encabezados context.h, nifs.h, term.h y portnifloader.h.

El bloque de LINK_OPTIONS es el que suele olvidarse y el que produce el error mas desconcertante: el firmware compila, arranca y el NIF no existe. La razon es el enlazador. Ninguna funcion de tu archivo objeto es referenciada explicitamente desde AtomVM (el registro ocurre por efecto colateral del constructor), asi que el enlazador considera que el objeto entero es basura y lo descarta al armar el binario. --whole-archive obliga a incluir todos los objetos del archivo, constructor incluido.

El Kconfig

menu "SampleApp Hello NIF Configuration"
    config AVM_SAMPLE_APP_HELLO_NIF_ENABLE
        bool "Enable SampleApp.Hello NIF example"
        default y
        help
            Enable the SampleApp.Hello NIF AtomVM component.
endmenu

Esto agrega una opcion a idf.py menuconfig y define CONFIG_AVM_SAMPLE_APP_HELLO_NIF_ENABLE en sdkconfig.h. El #ifdef alrededor de REGISTER_NIF_COLLECTION permite dejar el codigo en el arbol pero fuera del binario cuando no se necesita: recortar cuesta poco y ahorra flash. Si trabajas con una version antigua de ESP-IDF basada en Make, el equivalente es un component.mk con COMPONENT_ADD_INCLUDEDIRS y COMPONENT_SRCDIRS.

El lado Elixir

El modulo Elixir debe existir de todas formas, con un cuerpo que nunca se ejecuta en el dispositivo:

defmodule SampleApp.Hello do
  @moduledoc "En AtomVM, `hello/0` esta implementado en C como `Elixir.SampleApp.Hello:hello/0`."

  @spec hello() :: integer() | :nif_not_loaded
  def hello, do: :nif_not_loaded
end

El cuerpo :nif_not_loaded cumple dos funciones: hace que el proyecto compile y corra en tu computador (donde el NIF no existe), y te da un valor de retorno inequivoco si grabaste la aplicacion pero olvidaste grabar el firmware con el componente.

La aplicacion que lo usa:

defmodule SampleApp do
  def start() do
    loop(SampleApp.Hello.hello())
  end

  defp loop(msg) do
    IO.puts("El NIF dijo: #{inspect(msg)}")
    Process.sleep(1000)
    loop(msg)
  end
end

Y las partes relevantes del mix.exs:

def project do
  [app: :sample_app, version: "0.1.0", elixir: "~> 1.17", deps: deps(), atomvm: atomvm()]
end

defp deps do
  [{:exatomvm, git: "https://github.com/atomvm/ExAtomVM/"}]
end

def atomvm do
  [start: SampleApp, flash_offset: 0x250000]
end

El ciclo de compilacion completo

El firmware se construye una sola vez; la aplicacion se reconstruye en cada cambio de codigo Elixir.

# 1. Firmware (una vez, o cada vez que cambie el codigo C)
cd AtomVM/src/platforms/esp32
ln -s /ruta/a/mi_nif components/mi_nif
idf.py reconfigure     # obligatorio: ESP-IDF cachea la lista de componentes
idf.py build
idf.py flash

# 2. Aplicacion (en cada iteracion de Elixir)
cd /ruta/a/sample_app
mix atomvm.packbeam
mix atomvm.esp32.flash

Sin idf.py reconfigure el componente ni siquiera se compila, y el sintoma es identico al de olvidar --whole-archive. La salida esperada por consola serie es El NIF dijo: 1234 una vez por segundo.

Terminos, memoria y errores desde el lado C

El ejemplo anterior devuelve un entero y no toca memoria. Cualquier NIF util hace mas que eso, y ahi aparecen las reglas que hay que respetar.

Un term es una palabra de maquina que codifica el valor y su tipo. Los enteros pequenos, los atomos y los punteros a estructuras del heap caben todos en esa palabra con etiquetas en los bits bajos. Nunca se manipula directamente: se usan las funciones de term.h.

OperacionFuncionNotas
Verificar tipoterm_is_integer, term_is_atom, term_is_tuple, term_is_binary, term_is_listDevuelven bool, no lanzan
Leer enteroterm_to_int, term_to_uint8, term_to_int32Solo despues de verificar el tipo
Crear enteroterm_from_intNo requiere reservar heap para enteros pequenos
Leer tuplaterm_get_tuple_arity, term_get_tuple_elementEl indice es base 0
Crear tuplaterm_alloc_tuple, term_put_tuple_elementRequiere heap reservado previamente
Crear atomoglobalcontext_make_atom(global, ATOM_STR("\x2", "ok"))El primer byte es el largo del nombre
Reservar heapmemory_ensure_free, memory_ensure_free_with_rootsPuede disparar recoleccion de basura
Lanzar excepcionRAISE_ERROR(BADARG_ATOM), RAISE_ERROR(OUT_OF_MEMORY_ATOM)Retorna desde el NIF con una excepcion Erlang
Validar y lanzarVALIDATE_VALUE(argv[0], term_is_integer)Macro que combina verificacion y RAISE_ERROR

La regla que produce los errores mas dificiles de diagnosticar es esta: reservar memoria puede mover los terminos que ya tenias. memory_ensure_free ejecuta el recolector de basura si hace falta, y el recolector compacta el heap del proceso. Cualquier term que estuvieras guardando en una variable local de C queda apuntando a memoria vieja.

Hay dos maneras correctas de convivir con eso:

  1. Reservar todo el espacio que vas a necesitar antes de construir cualquier termino.
  2. Usar memory_ensure_free_with_roots, que recibe la lista de terminos que debe actualizar durante la recoleccion.

Aqui esta un NIF de dos argumentos que suma y devuelve {:ok, resultado}, aplicando ambas ideas:

// Ademas de los encabezados del ejemplo anterior hacen falta
// <defaultatoms.h> (para OK_ATOM, BADARG_ATOM) y <memory.h>.

// Elixir.Robot.Math:add/2
static term nif_add_2(Context *ctx, int argc, term argv[])
{
    (void) argc;

    // 1. Validar: si algun argumento no es entero, lanza :badarg.
    VALIDATE_VALUE(argv[0], term_is_integer);
    VALIDATE_VALUE(argv[1], term_is_integer);

    // 2. Convertir a tipos de C.
    avm_int_t sum = term_to_int(argv[0]) + term_to_int(argv[1]);

    // 3. Reservar de una sola vez el espacio de la tupla de 2 elementos.
    if (UNLIKELY(memory_ensure_free(ctx, TUPLE_SIZE(2)) != MEMORY_GC_OK)) {
        RAISE_ERROR(OUT_OF_MEMORY_ATOM);
    }

    // 4. Recien ahora construir terminos: nada mas reserva memoria
    //    entre este punto y el return.
    term result = term_alloc_tuple(2, &ctx->heap);
    term_put_tuple_element(result, 0, OK_ATOM);
    term_put_tuple_element(result, 1, term_from_int(sum));

    return result;
}

static const struct Nif add_2_nif = { .base.type = NIFFunctionType, .nif_ptr = nif_add_2 };

const struct Nif *mi_nif_get_nif(const char *nifname)
{
    return strcmp("Elixir.Robot.Math:add/2", nifname) == 0 ? &add_2_nif : NULL;
}

REGISTER_NIF_COLLECTION(mi_nif, NULL, NULL, mi_nif_get_nif)

Del lado Elixir basta el modulo espejo Robot.Math con def add(_a, _b), do: :nif_not_loaded. En el dispositivo, Robot.Math.add(20, 22) devuelve {:ok, 42} y Robot.Math.add(20, :hola) levanta ArgumentError, igual que cualquier funcion de la biblioteca estandar.

OK_ATOM, ERROR_ATOM, BADARG_ATOM y OUT_OF_MEMORY_ATOM estan predefinidos en defaultatoms.h; para cualquier atomo propio se usa globalcontext_make_atom con la cadena con prefijo de largo.

Cuatro reglas que no conviene romper

No bloquear. El NIF se ejecuta dentro del planificador cooperativo de AtomVM, en el mismo hilo. Un vTaskDelay de dos segundos, una lectura de red sin timeout o un lazo de espera activa congelan toda la maquina virtual: ningun proceso Elixir avanza, los temporizadores se atrasan y el watchdog puede reiniciar el chip. Si la operacion es larga, corresponde un port driver que la haga en su propia tarea.

No asumir el proceso llamador. El mismo NIF puede invocarse desde diez procesos a la vez. El estado compartido va en el GlobalContext o en variables protegidas, nunca en estaticas sin proteccion.

No fallar en silencio. {:error, razon} para condiciones esperadas; RAISE_ERROR para violaciones de contrato. Retornar un valor sin sentido convierte un problema de C en un problema de logica Elixir imposible de rastrear.

No filtrar memoria. Todo malloc necesita su free, y los recursos que sobreviven a la llamada se manejan con enif_alloc_resource y enif_release_resource, que atan el ciclo de vida del objeto nativo al recolector de basura de la VM.

Port drivers: procesos nativos con estado

Un port driver resuelve exactamente lo que el NIF no puede: mantener estado propio, sobrevivir entre llamadas y enviar mensajes por iniciativa propia a procesos Elixir. Un sensor con interrupciones, un puerto serie, un motor con lazo de control: todos son ports.

Del lado Elixir un port se abre con :erlang.open_port({:spawn, "contador"}, []), donde "contador" es el nombre registrado por la macro REGISTER_PORT_DRIVER. A partir de ahi, el modulo :port de AtomVM ofrece la interfaz sincronica :port.call(port, mensaje) y :port.call(port, mensaje, timeout_ms).

El protocolo real

:port.call/3 no es magia: es un intercambio de mensajes con un formato fijo. Vale la pena verlo porque es lo que tu codigo C debe entender.

sequenceDiagram
    participant P as Proceso Elixir
    participant D as Port driver (Context nativo)
    participant H as Hardware / ESP-IDF

    P->>P: MonitorRef = monitor(port, Port)
    P->>D: {'$call', {self(), MonitorRef}, {:increment, 5}}
    Note over D: native_handler despierta<br/>al llegar un mensaje al buzon
    D->>D: port_parse_gen_message(msg)<br/>extrae pid, ref y req
    D->>D: interop_atom_term_select_int<br/>traduce :increment a un enum
    D->>H: actualiza estado / escribe registro
    H-->>D: resultado
    D->>D: memory_ensure_free_with_roots<br/>reserva tupla de respuesta
    D-->>P: {MonitorRef, {:ok, 5}}
    P->>P: demonitor(MonitorRef, [flush])
    Note over P: :port.call devuelve {:ok, 5}

    Note over D,P: Ademas, en cualquier momento:
    H-->>D: interrupcion / evento
    D-->>P: port_send_message(pid, {:pulse, 1})

El mensaje de entrada es la tupla {:"$call", {pid_del_llamador, referencia}, peticion} y la respuesta es {referencia, resultado}. El monitor(port, Port) que hace :port.call sirve para no quedarse esperando para siempre si el driver muere: si llega un DOWN, devuelve {:error, razon}. Y si vence el timeout, devuelve {:error, :timeout}.

Un port driver completo

Este driver mantiene un contador en memoria nativa, responde a tres comandos y demuestra las piezas que necesitaras en cualquier driver real.

#include "contador_driver.h"

// context.h, defaultatoms.h, globalcontext.h, interop.h, mailbox.h,
// memory.h, port.h, portnifloader.h, scheduler.h, term.h, esp_log.h

#define TAG "contador_driver"

// Estado privado del port. Vive mientras vive el proceso nativo.
struct ContadorData { int32_t value; int32_t step; };

enum contador_cmd
{
    ContadorInvalidCmd = 0,
    ContadorIncrementCmd,
    ContadorReadCmd,
    ContadorResetCmd,
    ContadorCloseCmd
};

// Tabla de traduccion atomo -> entero. El primer byte de cada
// cadena es el largo del nombre del atomo.
static const AtomStringIntPair cmd_table[] = {
    { ATOM_STR("\x9", "increment"), ContadorIncrementCmd },
    { ATOM_STR("\x4", "read"), ContadorReadCmd },
    { ATOM_STR("\x5", "reset"), ContadorResetCmd },
    { ATOM_STR("\x5", "close"), ContadorCloseCmd },
    SELECT_INT_DEFAULT(ContadorInvalidCmd)
};

static term contador_increment(Context *ctx, term req)
{
    struct ContadorData *data = ctx->platform_data;

    if (term_get_tuple_arity(req) != 2 || !term_is_integer(term_get_tuple_element(req, 1))) {
        return BADARG_ATOM;
    }

    data->value += term_to_int(term_get_tuple_element(req, 1)) * data->step;
    return term_from_int(data->value);
}

// Manejador nativo: se ejecuta cada vez que llega un mensaje al buzon
// del proceso nativo que representa a este port.
static NativeHandlerResult consume_contador_mailbox(Context *ctx)
{
    Message *message = mailbox_first(&ctx->mailbox);
    GenMessage gen_message;

    if (UNLIKELY(port_parse_gen_message(message->message, &gen_message) != GenCallMessage)) {
        ESP_LOGW(TAG, "Mensaje invalido, se descarta.");
        mailbox_remove_message(&ctx->mailbox, &ctx->heap);
        return NativeContinue;
    }

    term req = gen_message.req;
    if (!term_is_tuple(req) || term_get_tuple_arity(req) < 1) {
        mailbox_remove_message(&ctx->mailbox, &ctx->heap);
        return NativeContinue;
    }

    enum contador_cmd cmd =
        interop_atom_term_select_int(cmd_table, term_get_tuple_element(req, 0), ctx->global);

    int local_process_id = term_to_local_process_id(gen_message.pid);
    struct ContadorData *data = ctx->platform_data;
    term ret;

    switch (cmd) {
        case ContadorIncrementCmd: ret = contador_increment(ctx, req); break;
        case ContadorReadCmd:      ret = term_from_int(data->value); break;
        case ContadorResetCmd:     data->value = 0; ret = OK_ATOM; break;
        case ContadorCloseCmd:     ret = OK_ATOM; break;
        default:                   ret = BADARG_ATOM;
    }

    // La respuesta es {Ref, Resultado}. Reservamos con raices porque
    // `ret` es un termino vivo que la recoleccion podria mover.
    term ret_msg;
    if (UNLIKELY(memory_ensure_free_with_roots(
            ctx, TUPLE_SIZE(2), 1, &ret, MEMORY_CAN_SHRINK) != MEMORY_GC_OK)) {
        ret_msg = OUT_OF_MEMORY_ATOM;
    } else {
        ret_msg = term_alloc_tuple(2, &ctx->heap);
        term_put_tuple_element(ret_msg, 0, gen_message.ref);
        term_put_tuple_element(ret_msg, 1, ret);
    }

    globalcontext_send_message(ctx->global, local_process_id, ret_msg);
    mailbox_remove_message(&ctx->mailbox, &ctx->heap);

    return cmd == ContadorCloseCmd ? NativeTerminate : NativeContinue;
}

// Se ejecuta al hacer open_port({spawn, "contador"}, Opts).
Context *contador_driver_create_port(GlobalContext *global, term opts)
{
    (void) opts;

    Context *ctx = context_new(global);
    struct ContadorData *data = malloc(sizeof(struct ContadorData));

    if (IS_NULL_PTR(data)) {
        ESP_LOGE(TAG, "Sin memoria para inicializar el driver.");
        scheduler_terminate(ctx);
        return NULL;
    }
    data->value = 0;
    data->step = 1;

    ctx->native_handler = consume_contador_mailbox;
    ctx->platform_data = data;

    return ctx;
}

REGISTER_PORT_DRIVER(contador, NULL, NULL, contador_driver_create_port)

Puntos a retener:

  • context_new(global) crea un proceso mas dentro del planificador de AtomVM. Tiene identificador de proceso, buzon y heap, igual que un proceso Elixir; lo que cambia es que su cuerpo es C.
  • ctx->native_handler es la funcion que corre cuando ese proceso tiene mensajes pendientes.
  • ctx->platform_data es el puntero libre donde guardas tu estado. Es lo que un NIF no tiene.
  • El valor de retorno del handler decide el destino del proceso: NativeContinue lo deja vivo, NativeTerminate lo termina.
  • REGISTER_PORT_DRIVER(contador, init, destroy, create) registra el nombre "contador", que es el que aparece en open_port({:spawn, "contador"}, []).

El envio espontaneo de eventos (por ejemplo desde una rutina de interrupcion) se hace con las funciones de port.h: port_send_message desde el hilo del planificador, o port_send_message_from_task cuando se llama desde otra tarea de FreeRTOS. El patron habitual en ESP32 es que la ISR solo encole el evento en una cola de FreeRTOS y una tarea dedicada lo saque y lo convierta en mensaje Elixir.

Del lado Elixir, el uso queda asi:

defmodule Robot.Contador do
  @moduledoc "Envoltorio Elixir del port driver nativo `contador`."

  def open, do: :erlang.open_port({:spawn, "contador"}, [])
  def increment(port, n \\ 1) when is_integer(n), do: :port.call(port, {:increment, n})
  def read(port), do: :port.call(port, {:read}, 1000)
  def reset(port), do: :port.call(port, {:reset})
  def close(port), do: :port.call(port, {:close})
end
iex> port = Robot.Contador.open()
iex> Robot.Contador.increment(port, 5)
5
iex> Robot.Contador.increment(port, 3)
8
iex> Robot.Contador.read(port)
8

Que el estado sobreviva entre llamadas independientes es justamente lo que distingue un port de un NIF.

Como elegir: NIF, port o Elixir puro

CriterioElixir puroNIFPort driver
Donde correBytecode en la VMC, dentro del proceso llamadorC, en su propio proceso nativo
Mantiene estado entre llamadasSi (proceso o ETS)NoSi (platform_data)
Puede enviar mensajes espontaneosSiNoSi
Puede bloquear sin colgar la VMSiNoSi, delegando a una tarea
Costo por llamadaAlto (interpretado)Muy bajoMedio (mensaje + respuesta)
Riesgo de tumbar el firmwareNuloAltoAlto
Se recarga sin regrabar firmwareSiNoNo
Complejidad de implementacionBajaMediaAlta
Caso tipicoLogica, maquinas de estado, supervisionCalculo matematico, conversion de datos, lectura de un registroSensor con interrupciones, UART, lazo de control, radio

La secuencia de decision que funciona en la practica: empieza en Elixir puro, que es lo unico que se itera sin regrabar el firmware; mide antes de bajar a C con System.monotonic_time alrededor del lazo critico; si necesitas velocidad en una funcion corta y sin estado, escribe un NIF; si necesitas estado, eventos asincronicos o una operacion que bloquea, escribe un port driver; y si el periferico ya lo cubre AtomVM o atomvm_lib, usa eso, porque el codigo nativo que no escribes es el que nunca falla.

Popcorn: el mismo AtomVM, pero dentro del navegador

Popcorn es una biblioteca de Software Mansion que compila AtomVM a WebAssembly y lo ejecuta en el navegador, con interoperabilidad bidireccional con JavaScript. Es el mismo runtime que corre en tu ESP32, portado a otro objetivo de compilacion.

Para un curso de robotica esto no es una curiosidad: es la forma mas barata de construir el panel de control del robot con la misma logica de dominio que corre en el dispositivo sin duplicarla en JavaScript, de simular la maquina de estados del robot en una pagina antes de tener el hardware, y de publicar demos ejecutables de un algoritmo de navegacion sin servidor.

Popcorn esta en desarrollo activo y su API cambia entre versiones menores. La guia original de Elixir Chile muestra el flujo de la serie 0.1, donde el proceso principal se registraba con Popcorn.Wasm.register/1. En la serie 0.3 esa funcion desaparecio y su rol lo cumplen Popcorn.Wasm.ready/1 y Popcorn.Wasm.set_default_receiver/1. Antes de copiar cualquier ejemplo, verifica la version que tienes instalada.

Arquitectura

flowchart TD
    subgraph BROWSER["Pestana del navegador"]
        JS["Tu codigo JS<br/>index.js"]
        POP["@swmansion/popcorn<br/>objeto Popcorn"]
        subgraph IFRAME["iframe aislado"]
            WASM["AtomVM compilado a WebAssembly"]
            AVM["bundle.avm<br/>tu codigo Elixir"]
            PROC["Procesos Elixir<br/>GenServer :main"]
        end
    end
    JS -->|"Popcorn.init(bundlePaths)"| POP
    POP -->|"crea y supervisa"| IFRAME
    WASM --> AVM
    AVM --> PROC
    JS -->|"popcorn.call(msg, {process})"| POP
    POP -->|"mensaje serializado"| PROC
    PROC -->|"Popcorn.Wasm.resolve"| POP
    POP -->|"resuelve la Promise"| JS
    PROC -->|"Popcorn.Wasm.run_js"| DOM["DOM de la pagina"]
    POP -.->|"heartbeat; si el iframe<br/>se cuelga lo recarga"| IFRAME

Dos decisiones de diseno explican el resto:

El VM corre en un iframe. Eso lo aisla del hilo principal de la pagina y permite que Popcorn lo vigile con un latido periodico. Si el runtime se cuelga o desborda, Popcorn recarga el iframe sin tumbar la pagina completa. La consecuencia practica es que dentro de una funcion JS ejecutada por run_js la variable window esta sombreada, y para llegar a la ventana real del iframe se usa el argumento iframeWindow.

La comunicacion es por mensajes serializados. No hay memoria compartida entre JS y Elixir. Todo argumento y todo retorno pasa por serializacion, lo que tiene un costo. Por eso Popcorn ofrece los tracked objects: referencias a valores que se quedan del lado JavaScript y que solo cruzan la frontera cuando pides su valor.

Puesta en marcha

En mix.exs y config/config.exs:

# mix.exs
def application, do: [extra_applications: [:logger]]
defp deps, do: [{:popcorn, "~> 0.3"}]

# config/config.exs
config :popcorn,
  out_dir: "dist/wasm",
  runtime: {:path, "popcorn_runtime_source/artifacts/wasm", target: :wasm}

Y las tareas Mix disponibles:

TareaQue hace
mix popcorn.build_runtime --target wasmCompila AtomVM desde fuente hacia WebAssembly y deja los artefactos en popcorn_runtime_source/artifacts/wasm
mix popcorn.cookCompila tu proyecto y arma el bundle .avm en out_dir
mix popcorn.cook --start-module WasmCounterIgual, pero declara el modulo con start/0 que se ejecuta al arrancar
mix popcorn.gen.jsGenera el andamiaje de assets/: package.json, build.mjs, index.js, index.html
mix popcorn.serverLevanta un servidor estatico local para probar el resultado

La secuencia tipica la primera vez:

mix deps.get
mix popcorn.build_runtime --target wasm
mix popcorn.gen.js
npm install --prefix assets
mix popcorn.cook --start-module WasmCounter
npm run build --prefix assets
mix popcorn.server

El paso build_runtime es el unico lento: compila la maquina virtual entera. Los siguientes ciclos solo repiten mix popcorn.cook y el empaquetado de assets. Una restriccion que sorprende: solo puedes declarar en extra_applications las aplicaciones presentes en el runtime que compilaste (tipicamente kernel, stdlib, compiler, elixir, logger). Agregar otra hace fallar el empaquetador con un error de dependencia faltante.

El lado Elixir

defmodule WasmCounter do
  @moduledoc "Contador en el navegador: recibe comandos de JS, guarda estado y actualiza el DOM."

  use GenServer
  require Popcorn.Wasm

  alias Popcorn.Wasm

  @process_name :main

  # Punto de entrada declarado con --start-module
  def start do
    {:ok, _pid} = GenServer.start_link(__MODULE__, %{count: 0}, name: @process_name)
    Process.sleep(:infinity)
  end

  @impl true
  def init(state) do
    render(state)

    # Registra este proceso como receptor por defecto y avisa a JS que la
    # aplicacion esta lista. Sin esta llamada, la promesa que devuelve
    # Popcorn.init() del lado JS nunca se resuelve.
    :ok = Wasm.ready(@process_name)

    {:ok, state}
  end

  @impl true
  def handle_info(raw_msg, state) when Wasm.is_wasm_message(raw_msg) do
    {:noreply, Wasm.handle_message!(raw_msg, &handle_wasm(&1, state))}
  end

  def handle_info(other, state) do
    IO.puts("Mensaje no reconocido: #{inspect(other)}")
    {:noreply, state}
  end

  # --- Mensajes provenientes de JavaScript ---

  # popcorn.call espera respuesta: {:resolve | :reject, respuesta, nuevo_estado}
  defp handle_wasm({:wasm_call, %{"action" => "increment", "by" => by}}, state) do
    new_state = %{state | count: state.count + by}
    render(new_state)
    {:resolve, %{count: new_state.count}, new_state}
  end

  defp handle_wasm({:wasm_call, unknown}, state) do
    IO.puts("Accion desconocida: #{inspect(unknown)}")
    {:reject, %{error: "unknown_action"}, state}
  end

  # popcorn.cast no espera respuesta: devolvemos solo el nuevo estado
  defp handle_wasm({:wasm_cast, "reset"}, state) do
    new_state = %{state | count: 0}
    render(new_state)
    new_state
  end

  defp handle_wasm({:wasm_cast, other}, state) do
    IO.puts("Cast no reconocido: #{inspect(other)}")
    state
  end

  # --- Actualizacion del DOM ---

  defp render(%{count: count}) do
    Wasm.run_js(
      """
      ({ args }) => {
        const el = document.querySelector("#count");
        if (el) { el.textContent = String(args.count); }
        return [];
      }
      """,
      %{count: count}
    )

    :ok
  end
end

Detalles que hacen que este codigo funcione:

  • require Popcorn.Wasm es obligatorio porque is_wasm_message/1 es una macro de guarda.
  • Wasm.ready(@process_name) cumple dos roles a la vez: registra el proceso bajo ese nombre y emite el evento que resuelve la promesa de Popcorn.init() del lado JavaScript. Si lo olvidas, el navegador se queda esperando para siempre.
  • handle_message!/2 deserializa el mensaje, llama a tu funcion y, cuando se trata de un :wasm_call, resuelve o rechaza la promesa segun devuelvas {:resolve, ...} o {:reject, ...}. El tercer elemento de la tupla es lo que handle_message! retorna, y por eso lo usamos para el nuevo estado.
  • La funcion JS que recibe run_js es una funcion flecha en texto que toma un objeto con las claves args, wasm e iframeWindow, y debe devolver un arreglo de valores a rastrear. Si no necesitas devolver nada, devuelve [].

La forma exacta en que llegan los objetos JavaScript deserializados (mapa con claves string, mapa con claves atomo, lista) depende de la version de Popcorn. La primera vez que conectes ambos lados, pon un IO.inspect/1 dentro del handler y lee la consola del navegador: es mas rapido que adivinar.

El lado JavaScript

assets/index.js:

import { Popcorn } from "@swmansion/popcorn";

const popcorn = await Popcorn.init({
  bundlePaths: ["/wasm/bundle.avm"],
  onStdout: console.log,
  onStderr: console.error,
});

document.querySelector("#inc").addEventListener("click", async () => {
  const result = await popcorn.call(
    { action: "increment", by: 1 },
    { process: "main", timeoutMs: 5000 },
  );
  console.log("respuesta de Elixir:", result.data, `${result.durationMs} ms`);
});

document.querySelector("#reset").addEventListener("click", () => {
  popcorn.cast("reset", { process: "main" });
});

El assets/index.html solo necesita cargar ./index.js como modulo y ofrecer los tres elementos que el codigo referencia: <div id="count">0</div>, <button id="inc"> y <button id="reset">.

El archivo assets/build.mjs, generado por mix popcorn.gen.js, usa esbuild con el plugin @swmansion/popcorn/esbuild, que se encarga de copiar el bundle .avm al directorio de salida y dejarlo accesible en la ruta que declaraste en bundlePaths.

El viaje completo de un clic es entonces: popcorn.call serializa el mensaje y lo envia al iframe; el runtime lo entrega al proceso :main como {:emscripten, {:call, promise, binario}}; handle_info lo reconoce con la guarda, handle_message! lo deserializa, tu clausula actualiza el estado y dispara run_js sobre el DOM, y el {:resolve, ...} viaja de vuelta hasta resolver la promesa de JavaScript con result.data y result.durationMs.

Que tener en cuenta

TemaSituacion
MadurezBeta declarada. La API cambia entre versiones menores
Tamano del bundleEl runtime WASM mas la biblioteca estandar pesan; existe una opcion experimental de treeshaking que recorta modulos y funciones sin usar a costa de perder informacion de archivo y linea en los stacktraces
Aplicaciones OTPSolo las incluidas en el runtime compilado
ConcurrenciaLos procesos Elixir son reales, pero todos comparten un unico hilo de WebAssembly
Acceso al DOMSiempre a traves de run_js; no hay binding directo
Versiones de herramientasSensible a la combinacion Elixir/Erlang usada para compilar; usa la que indique la version de Popcorn que instalaste

Control remoto: el ESP32 como su propio punto de acceso

Volvamos al hardware. Tienes un robot con motores y un ESP32. Quieres manejarlo desde el telefono. La solucion sin infraestructura es que el propio ESP32 levante una red WiFi, sirva una pagina y reciba comandos por HTTP.

flowchart LR
    subgraph PHONE["Telefono"]
        B["Navegador<br/>192.168.4.1:8080"]
    end
    subgraph ESP["ESP32 con AtomVM"]
        AP["network<br/>Access Point<br/>SSID robot-test"]
        HTTPD["httpd de atomvm_lib<br/>puerto 8080"]
        FH["httpd_file_handler<br/>sirve priv/"]
        AH["httpd_api_handler<br/>ruta /api"]
        MOV["GenServer Robot.Motion<br/>gpio digital_write"]
    end
    DRV["Puente H<br/>L298N o TB6612"]
    M["Motores DC"]

    B -->|"asociacion WiFi"| AP
    B -->|"GET /index.html"| HTTPD
    HTTPD --> FH
    FH -->|"HTML, CSS, JS"| B
    B -->|"POST /api/move<br/>cuerpo: forward"| HTTPD
    HTTPD --> AH
    AH --> MOV
    MOV --> DRV
    DRV --> M

Dependencias

El servidor HTTP no viene en el nucleo de AtomVM: lo aporta atomvm_lib, la biblioteca de utilidades del proyecto.

defp deps do
  [
    {:exatomvm, git: "https://github.com/atomvm/ExAtomVM/"},
    {:atomvm_lib, git: "https://github.com/atomvm/atomvm_lib/"}
  ]
end

def atomvm do
  [start: RobotCtl, flash_offset: 0x250000]
end

Levantar el punto de acceso

El modulo network de AtomVM acepta una configuracion con la clave :ap. Estas son las propiedades disponibles:

PropiedadTipoPara que sirve
ssidcadena o binarioNombre de la red que veran los telefonos
pskcadena o binarioContrasena WPA2. Minimo ocho caracteres; si se omite, la red queda abierta
ap_channelenteroCanal WiFi a usar
ap_ssid_hiddenbooleanoOculta el SSID del anuncio
ap_max_connectionsenteroLimite de clientes asociados
ap_startedfuncion de aridad 0Se invoca cuando el AP quedo operativo
sta_connectedfuncion de aridad 1Recibe la MAC del cliente que se asocio
sta_ip_assignedfuncion de aridad 1Recibe la IP que el DHCP interno entrego
sta_disconnectedfuncion de aridad 1Recibe la MAC del cliente que se fue
defp start_ap do
  ap_config = [
    ssid: "robot-test",
    psk: "robot1234",
    ap_max_connections: 4,
    ap_started: fn -> IO.puts("Punto de acceso operativo en 192.168.4.1") end,
    sta_connected: fn mac -> IO.puts("Cliente asociado: #{inspect(mac)}") end,
    sta_ip_assigned: fn ip -> IO.puts("IP entregada: #{inspect(ip)}") end,
    sta_disconnected: fn mac -> IO.puts("Cliente desconectado: #{inspect(mac)}") end
  ]

  case :network.wait_for_ap(ap_config, 30_000) do
    :ok ->
      IO.puts("Red lista")
      :ok

    error ->
      IO.puts("Error levantando el AP: #{inspect(error)}")
      error
  end
end

:network.wait_for_ap/2 arranca el subsistema y bloquea hasta que el AP este operativo o hasta agotar el timeout. Es la forma limpia de no seguir adelante con un servidor HTTP sobre una interfaz que todavia no existe. La direccion 192.168.4.1 es la que ESP-IDF asigna al AP por omision, y el DHCP interno reparte direcciones en esa subred.

El servidor HTTP

httpd de atomvm_lib recibe un puerto y una lista de rutas. Cada ruta es una tupla {prefijo, configuracion} donde el prefijo es una lista de segmentos binarios y la configuracion indica que manejador usar.

defmodule RobotCtl do
  @moduledoc """
  Punto de acceso WiFi + servidor HTTP para manejar el robot
  desde el navegador del telefono.
  """

  @behaviour :httpd_api_handler

  @port 8080

  def start do
    :ok = start_ap()
    {:ok, _pid} = Robot.Motion.start_link([])

    config = [
      {["api"], %{handler: :httpd_api_handler, handler_config: %{module: __MODULE__}}},
      {[], %{handler: :httpd_file_handler, handler_config: %{app: :robot_ctl}}}
    ]

    IO.puts("Levantando httpd en el puerto #{@port} ...")

    case :httpd.start(@port, config) do
      {:ok, _pid} ->
        IO.puts("Listo. Conectate a la red y abre http://192.168.4.1:#{@port}/index.html")
        Process.sleep(:infinity)

      error ->
        IO.puts("No se pudo iniciar httpd: #{inspect(error)}")
        error
    end
  end

  # --- Manejador de la API ---

  @impl :httpd_api_handler
  def handle_api_request(:post, ["move"], http_request, _args) do
    direction = parse_direction(Map.get(http_request, :body, <<>>))
    :ok = Robot.Motion.command(direction)
    {:ok, %{status: "ok", direction: to_string(direction)}}
  end

  def handle_api_request(:get, ["status"], _http_request, _args) do
    {:ok,
     %{
       platform: :atomvm.platform(),
       direction: to_string(Robot.Motion.current()),
       free_heap: :erlang.system_info(:esp32_free_heap_size),
       process_count: :erlang.system_info(:process_count)
     }}
  end

  def handle_api_request(method, path, _http_request, _args) do
    IO.puts("Ruta no soportada: #{inspect(method)} #{inspect(path)}")
    :not_found
  end

  # --- Auxiliares ---

  defp parse_direction(<<"forward">>), do: :forward
  defp parse_direction(<<"back">>), do: :back
  defp parse_direction(<<"left">>), do: :left
  defp parse_direction(<<"right">>), do: :right
  defp parse_direction(_), do: :stop

  defp start_ap do
    # ... la funcion definida en la seccion anterior
  end
end

Cosas que conviene entender de este modulo:

  • La ruta {["api"], ...} captura todo lo que empiece con /api, y el manejador recibe en PathSuffix el resto del camino ya segmentado: para /api/move recibe ["move"].
  • La ruta {[], ...} es el comodin final: cualquier cosa que no coincida con una ruta anterior la atiende httpd_file_handler.
  • handler_config: %{app: :robot_ctl} le dice al manejador de archivos que sirva los archivos desde el directorio priv de esa aplicacion dentro del bundle.
  • El valor de retorno decide la respuesta: {:ok, mapa} se codifica como JSON con Content-Type: application/json; {:ok, cadena} se devuelve como texto plano; :not_found, :bad_request e :internal_server_error producen los codigos de estado correspondientes.
  • @behaviour :httpd_api_handler no es decorativo: hace que el compilador te avise si la firma de handle_api_request/4 no coincide.
  • El mapa http_request trae, entre otras claves, :method, :headers, :body y :socket. Con el socket y :socket.peername/1 puedes registrar quien envio cada comando.

Las rutas expuestas

MetodoRutaCuerpoRespuesta
GET/index.htmlLa pagina de control desde priv/
POST/api/moveforward, back, left, right, stop{"status":"ok","direction":"forward"}
GET/api/statusPlataforma, direccion actual, memoria libre, procesos
cualquieraotra404

El proceso que mueve los motores

El manejador HTTP no debe hablarle al hardware directamente: si dos peticiones llegan a la vez, dos procesos escribirian pines al mismo tiempo. Un GenServer serializa los comandos y guarda el estado actual.

defmodule Robot.Motion do
  @moduledoc "Traduce comandos a niveles logicos en el puente H. Un solo proceso posee los pines."

  use GenServer

  @in1 18  # motor izquierdo
  @in2 19
  @in3 21  # motor derecho
  @in4 22

  @directions [:forward, :back, :left, :right, :stop]

  def start_link(_opts), do: GenServer.start_link(__MODULE__, :stop, name: __MODULE__)

  @spec command(atom()) :: :ok | {:error, :invalid_direction}
  def command(direction) when direction in @directions,
    do: GenServer.call(__MODULE__, {:move, direction})

  def command(_), do: {:error, :invalid_direction}

  @spec current() :: atom()
  def current, do: GenServer.call(__MODULE__, :current)

  @impl true
  def init(initial) do
    for pin <- [@in1, @in2, @in3, @in4] do
      :gpio.set_pin_mode(pin, :output)
      :gpio.digital_write(pin, :low)
    end

    {:ok, initial}
  end

  @impl true
  def handle_call({:move, direction}, _from, _state) do
    apply_direction(direction)
    {:reply, :ok, direction}
  end

  def handle_call(:current, _from, state), do: {:reply, state, state}

  # --- Traduccion direccion -> pines ---
  # {izq_adelante, izq_atras, der_adelante, der_atras}

  defp apply_direction(:forward), do: write({:high, :low, :high, :low})
  defp apply_direction(:back), do: write({:low, :high, :low, :high})
  defp apply_direction(:left), do: write({:low, :high, :high, :low})
  defp apply_direction(:right), do: write({:high, :low, :low, :high})
  defp apply_direction(:stop), do: write({:low, :low, :low, :low})

  defp write({a, b, c, d}) do
    :gpio.digital_write(@in1, a)
    :gpio.digital_write(@in2, b)
    :gpio.digital_write(@in3, c)
    :gpio.digital_write(@in4, d)
    :ok
  end
end

La maquina de estados del movimiento queda explicita:

stateDiagram-v2
    [*] --> Detenido
    Detenido --> Adelante: forward
    Detenido --> Atras: back
    Detenido --> GiroIzq: left
    Detenido --> GiroDer: right
    Adelante --> Detenido: stop
    Atras --> Detenido: stop
    GiroIzq --> Detenido: stop
    GiroDer --> Detenido: stop
    Adelante --> GiroIzq: left
    Adelante --> GiroDer: right
    note right of Detenido
        Cuatro pines en nivel bajo: estado seguro
        tras el arranque y ante comandos desconocidos.
    end note

La interfaz web

El archivo va en priv/index.html del proyecto. mix atomvm.packbeam empaqueta el contenido de priv/ dentro del .avm bajo el prefijo <nombre_app>/priv, que es exactamente donde httpd_file_handler lo busca cuando le pasas app: :robot_ctl.

<!doctype html>
<html lang="es">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Control del robot</title>
    <style>
      :root { color-scheme: dark; --btn: #2b3440; --fg: #f2f4f8; }
      body { margin: 0; padding: 1.5rem; background: #14171c; color: var(--fg);
             font-family: system-ui, sans-serif; text-align: center; touch-action: manipulation; }
      #pad { display: grid; grid-template-columns: repeat(3, 5.5rem); grid-template-rows: repeat(3, 5.5rem);
             gap: 0.6rem; justify-content: center; margin: 1.5rem auto; }
      button { border: 0; border-radius: 0.9rem; background: var(--btn); color: var(--fg); font-size: 1.6rem; }
      button:active { background: #3d8bfd; }
      #fwd { grid-area: 1 / 2; } #left { grid-area: 2 / 1; } #right { grid-area: 2 / 3; } #back { grid-area: 3 / 2; }
      #stop { grid-area: 2 / 2; background: #c0392b; font-size: 1rem; }
      #status { font-size: 0.9rem; opacity: 0.75; min-height: 1.4rem; }
    </style>
  </head>
  <body>
    <h1>Control del robot</h1>
    <div id="pad">
      <button id="fwd"   data-dir="forward">&#9650;</button>
      <button id="left"  data-dir="left">&#9664;</button>
      <button id="stop"  data-dir="stop">STOP</button>
      <button id="right" data-dir="right">&#9654;</button>
      <button id="back"  data-dir="back">&#9660;</button>
    </div>
    <p id="status">Sin comandos enviados</p>
    <script>
      const status = document.getElementById("status");

      async function send(direction) {
        try {
          const response = await fetch("/api/move", {
            method: "POST",
            headers: { "Content-Type": "text/plain" },
            body: direction,
          });
          if (!response.ok) {
            status.textContent = "Error HTTP " + response.status;
            return;
          }
          const data = await response.json();
          status.textContent = "Robot en: " + data.direction;
        } catch (error) {
          status.textContent = "Sin conexion con el robot";
        }
      }

      for (const button of document.querySelectorAll("#pad button")) {
        button.addEventListener("click", () => send(button.dataset.dir));
        // Soltar el boton detiene: comportamiento tipo "hombre muerto".
        if (button.id !== "stop") {
          button.addEventListener("pointerup", () => send("stop"));
          button.addEventListener("pointerleave", () => send("stop"));
        }
      }
    </script>
  </body>
</html>

El comportamiento de “hombre muerto” (soltar el boton detiene el robot) no es un adorno: en un vehiculo que se mueve, un comando de avance sin comando de detencion es la receta para que el robot se estrelle cuando el telefono pierde la conexion o la pagina se congela.

La secuencia de arranque completa

sequenceDiagram
    participant E as ESP32
    participant N as network (AP)
    participant H as httpd
    participant T as Telefono
    participant M as Robot.Motion

    E->>N: network:wait_for_ap(config, 30000)
    N-->>E: :ok  (callback ap_started)
    E->>M: start_link: 4 pines en nivel bajo
    E->>H: httpd:start(8080, rutas)
    H-->>E: {:ok, pid} y Process.sleep(:infinity)
    T->>N: se asocia a la red "robot-test"
    N-->>T: IP por DHCP (192.168.4.x)
    T->>H: GET /index.html
    H-->>T: HTML desde priv/
    T->>H: POST /api/move  cuerpo "forward"
    H->>M: Robot.Motion.command(:forward)
    M->>M: escribe niveles en el puente H
    H-->>T: {"status":"ok","direction":"forward"}

Limites y seguridad de este montaje

Un punto de acceso WPA2 con contrasena compartida es adecuado para una demo o un taller, no para un dispositivo desplegado. Las limitaciones reales:

  • Cualquiera con la contrasena controla el robot. No hay autenticacion por usuario ni por sesion. Si el proyecto lo requiere, lo minimo es un token en un encabezado que el manejador verifique antes de tocar los motores.
  • El trafico va en HTTP plano. Dentro del AP local eso es menos grave, pero cualquier cliente asociado puede observar el trafico de los demas.
  • El AP no da acceso a Internet. El telefono, al detectar que la red no navega, puede saltar a datos moviles y dejar de resolver 192.168.4.1. En Android e iOS conviene desactivar el cambio automatico de red mientras se usa el robot.
  • Sin control de tiempo de vida del comando. Si el telefono se apaga con el robot avanzando, el robot sigue avanzando. La solucion es un temporizador en Robot.Motion que detiene el vehiculo si no recibe un comando dentro de una ventana (por ejemplo 500 ms), con el navegador reenviando el comando periodicamente mientras el boton este presionado.
  • Cantidad de clientes limitada. ap_max_connections existe porque el radio del ESP32 no atiende decenas de asociaciones.

Las tres extensiones vistas juntas

AspectoNIFPort driverPopcornServidor HTTP en el dispositivo
Lenguaje del codigo nuevoCCElixir + JavaScriptElixir + HTML/JS
Requiere regrabar firmwareSiSiNo aplicaNo
Donde se ejecutaEn el chipEn el chipEn el navegadorEn el chip, interfaz en el navegador
Necesita redNoNoSi (para servir la pagina)Si (AP o router)
Ciclo de iteracionLentoLentoRapidoRapido
Riesgo de dejar el chip inutilizableAltoAltoNuloBajo
Cuando usarloPeriferico o calculo no soportadoPeriferico con estado o eventosPanel, simulacion, demoTeleoperacion, configuracion, diagnostico

Un proyecto maduro usa varias a la vez: un port driver para el encoder de las ruedas, Elixir puro para la maquina de estados de navegacion, un servidor HTTP para el panel de teleoperacion, y Popcorn para publicar una simulacion del algoritmo que cualquiera puede abrir en una pagina.

Errores comunes

SintomaCausaSolucion
El firmware compila pero el NIF devuelve :nif_not_loadedEl enlazador descarto el objeto porque nada lo referenciaAgregar el bloque LINK_OPTIONS con -Wl,--whole-archive sobre la biblioteca del componente
El componente ni siquiera se compilaESP-IDF cachea la lista de componentesEjecutar idf.py reconfigure antes de idf.py build
undefined reference a term_from_int o context_newFalta declarar la dependencia del componenteAgregar PRIV_REQUIRES "libatomvm" "avm_sys" en idf_component_register
El NIF nunca se resuelve aunque el codigo esta enlazadoEl nombre buscado no coincideUsar exactamente "Elixir.Modulo.Submodulo:funcion/aridad", con el prefijo Elixir.
Valores corruptos o reinicios aleatorios tras construir una tuplaSe reservo memoria despues de tener terminos vivos y la recoleccion los movioReservar todo el heap antes de construir, o usar memory_ensure_free_with_roots
El chip se reinicia por watchdog al llamar una funcion nativaEl NIF bloquea el unico hilo de la VMConvertir la operacion en port driver y delegarla a una tarea de FreeRTOS
:port.call devuelve {:error, :timeout}El driver no responde con {Ref, Resultado} o no consume el mensaje del buzonVerificar port_parse_gen_message, la construccion de la respuesta y la llamada a mailbox_remove_message
open_port falla con :badargEl nombre del driver no coincide con el de REGISTER_PORT_DRIVEREl primer argumento de la macro se convierte en cadena tal cual; usar el mismo texto en {:spawn, "nombre"}
En Popcorn, Popcorn.init() nunca resuelve la promesaLa aplicacion Elixir nunca aviso que estaba listaLlamar Popcorn.Wasm.ready/0,1 desde el proceso principal, tipicamente en init/1
popcorn.call responde “Unspecified target process”No hay receptor por defecto ni se paso processPasar { process: "main" } en las opciones, o registrar el proceso con Popcorn.Wasm.ready(:main)
run_js no cambia nada en la paginaLa funcion JS no devuelve un arreglo, o consulta el DOM del iframe en vez del de la paginaDevolver siempre un arreglo ([] si no hay valores) y revisar si corresponde usar iframeWindow
El telefono se conecta al AP pero no carga la paginaEl navegador uso datos moviles al detectar una red sin InternetDesactivar el cambio automatico de red; escribir la URL completa con el puerto: http://192.168.4.1:8080/index.html
/api/move responde pero el robot no se mueveEl manejador HTTP escribe pines desde varios procesos, o el puente H no comparte tierra con el ESP32Serializar en un GenServer unico y verificar la masa comun con el multimetro
httpd falla al arrancar o nadie lo alcanzaSe levanto antes de que existiera la interfaz de redUsar :network.wait_for_ap/2 y continuar solo con :ok
Los archivos de priv/ dan 404El bundle no los incluye o el app del manejador no coincideConfirmar que mix atomvm.packbeam empaqueto priv/ y que handler_config: %{app: :nombre_app} usa el nombre correcto
El robot sigue avanzando cuando el telefono se desconectaNo hay temporizador de seguridadDetener automaticamente si no llega un comando dentro de una ventana de tiempo

Ejercicios propuestos

  1. Tu primer NIF. Reproduce el ejemplo hello/0 completo: componente, Kconfig, CMakeLists.txt, firmware regrabado y aplicacion Elixir. Despues quita a proposito el bloque LINK_OPTIONS, recompila y comprueba que el sintoma es exactamente :nif_not_loaded. Documenta el diagnostico en tres lineas.

  2. NIF con validacion y presupuesto de heap. Escribe Robot.Math.clamp/3, que acote un valor entre dos limites, lance :badarg si algun argumento no es entero y devuelva {:error, :invalid_range} si el limite inferior supera al superior; nota la diferencia de criterio entre violacion de contrato y condicion esperada. Despues hazlo devolver una tupla de cuatro elementos, calcula a mano las palabras que necesita, contrastalo con TUPLE_SIZE(4) y reduce la reserva a proposito para observar el comportamiento en el dispositivo.

  3. Port driver con eventos. Extiende el driver contador con un comando {:subscribe} que guarde el identificador del proceso llamador. Agrega una tarea de FreeRTOS que cada segundo incremente el contador y envie {:tick, valor} al suscriptor con port_send_message_from_task. Del lado Elixir, un GenServer que reciba esos mensajes y los imprima.

  4. NIF contra port, medido. Implementa la misma operacion (por ejemplo el promedio de un arreglo de 100 enteros) de tres formas: en Elixir puro, como NIF y como port driver. Mide con System.monotonic_time/1 el tiempo de mil invocaciones de cada una y arma una tabla con los resultados. Explica por que el port es mas lento que el NIF aunque el codigo C sea identico.

  5. Contador en el navegador. Levanta el proyecto Popcorn completo del capitulo. Despues agrega un tercer boton que envie un popcorn.cast con un valor arbitrario y verifica en la consola que el IO.inspect del handler te muestra la forma exacta en que llega el dato deserializado. Anota esa forma.

  6. Simulador de la maquina de estados. Toma Robot.Motion y crea una version que, en vez de escribir pines, dibuje la direccion actual en el DOM con run_js. Sirvela con Popcorn. Ahora tienes la misma maquina de estados corriendo en el chip y en el navegador desde el mismo codigo fuente.

  7. Teleoperacion con temporizador de seguridad. Modifica Robot.Motion para que guarde la marca de tiempo del ultimo comando y un proceso auxiliar detenga el robot si pasan mas de 500 ms sin recibir uno. Del lado del navegador, reenvia el comando cada 200 ms mientras el boton este presionado. Comprueba el comportamiento apagando el WiFi del telefono con el robot en movimiento.

  8. Endpoint de diagnostico y autenticacion minima. Agrega GET /api/telemetry con memoria libre, cantidad de procesos y ultima direccion comandada, refrescado en la pagina cada dos segundos. Despues exige un token compartido en un encabezado y rechaza con :bad_request cualquier /api/move que no lo traiga. Discute por escrito por que ese token sigue siendo insuficiente frente a alguien conectado al mismo AP.

  9. Diagrama de tu extension. Elige un periferico que quieras soportar y decide, con la tabla de este capitulo, si corresponde NIF o port. Dibuja en Mermaid el diagrama de secuencia completo de una operacion, desde la llamada Elixir hasta el registro del hardware y de vuelta.

Que viene despues

Ya no dependes de lo que AtomVM trae de fabrica. Sabes escribir una funcion en C y llamarla desde Elixir, sabes cuando eso no alcanza y hay que construir un proceso nativo con estado y eventos propios, sabes llevar el mismo runtime al navegador con Popcorn para prototipar sin hardware, y sabes convertir el ESP32 en un punto de acceso que sirve su propia interfaz de teleoperacion. Con eso cubres el ciclo completo: bajar hasta el silicio cuando hace falta y subir hasta la pantalla del telefono cuando conviene.

En el capitulo 15 dejamos las tuberias y pasamos a los proyectos que las usan todas juntas. Vamos a recorrer Arcade, la maquina de juegos sobre AtomVM que combina entrada de botones, pantalla y lazo de renderizado; Colosseo, donde varios dispositivos compiten coordinandose por mensajes; y Tagboard, el tablero de etiquetas que integra sensores, red y una interfaz web como la que acabas de construir. Son proyectos completos, no ejercicios: cada uno muestra como se combinan procesos, supervision, drivers y red en algo que funciona de punta a punta.

El indice completo del curso esta en /tecnologias/elixir-robotics/00-indice/.