Extender AtomVM: NIFs, ports, Popcorn y control remoto
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[]);
ctxes el contexto del proceso Elixir que hizo la llamada: contiene su heap, su buzon y un puntero alGlobalContextde toda la VM.argces la cantidad de argumentos.argves el arreglo de argumentos, cada uno unterm: 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.
| Operacion | Funcion | Notas |
|---|---|---|
| Verificar tipo | term_is_integer, term_is_atom, term_is_tuple, term_is_binary, term_is_list | Devuelven bool, no lanzan |
| Leer entero | term_to_int, term_to_uint8, term_to_int32 | Solo despues de verificar el tipo |
| Crear entero | term_from_int | No requiere reservar heap para enteros pequenos |
| Leer tupla | term_get_tuple_arity, term_get_tuple_element | El indice es base 0 |
| Crear tupla | term_alloc_tuple, term_put_tuple_element | Requiere heap reservado previamente |
| Crear atomo | globalcontext_make_atom(global, ATOM_STR("\x2", "ok")) | El primer byte es el largo del nombre |
| Reservar heap | memory_ensure_free, memory_ensure_free_with_roots | Puede disparar recoleccion de basura |
| Lanzar excepcion | RAISE_ERROR(BADARG_ATOM), RAISE_ERROR(OUT_OF_MEMORY_ATOM) | Retorna desde el NIF con una excepcion Erlang |
| Validar y lanzar | VALIDATE_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:
- Reservar todo el espacio que vas a necesitar antes de construir cualquier termino.
- 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_handleres la funcion que corre cuando ese proceso tiene mensajes pendientes.ctx->platform_dataes 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:
NativeContinuelo deja vivo,NativeTerminatelo termina. REGISTER_PORT_DRIVER(contador, init, destroy, create)registra el nombre"contador", que es el que aparece enopen_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
| Criterio | Elixir puro | NIF | Port driver |
|---|---|---|---|
| Donde corre | Bytecode en la VM | C, dentro del proceso llamador | C, en su propio proceso nativo |
| Mantiene estado entre llamadas | Si (proceso o ETS) | No | Si (platform_data) |
| Puede enviar mensajes espontaneos | Si | No | Si |
| Puede bloquear sin colgar la VM | Si | No | Si, delegando a una tarea |
| Costo por llamada | Alto (interpretado) | Muy bajo | Medio (mensaje + respuesta) |
| Riesgo de tumbar el firmware | Nulo | Alto | Alto |
| Se recarga sin regrabar firmware | Si | No | No |
| Complejidad de implementacion | Baja | Media | Alta |
| Caso tipico | Logica, maquinas de estado, supervision | Calculo matematico, conversion de datos, lectura de un registro | Sensor 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:
| Tarea | Que hace |
|---|---|
mix popcorn.build_runtime --target wasm | Compila AtomVM desde fuente hacia WebAssembly y deja los artefactos en popcorn_runtime_source/artifacts/wasm |
mix popcorn.cook | Compila tu proyecto y arma el bundle .avm en out_dir |
mix popcorn.cook --start-module WasmCounter | Igual, pero declara el modulo con start/0 que se ejecuta al arrancar |
mix popcorn.gen.js | Genera el andamiaje de assets/: package.json, build.mjs, index.js, index.html |
mix popcorn.server | Levanta 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.Wasmes obligatorio porqueis_wasm_message/1es 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 dePopcorn.init()del lado JavaScript. Si lo olvidas, el navegador se queda esperando para siempre.handle_message!/2deserializa 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 quehandle_message!retorna, y por eso lo usamos para el nuevo estado.- La funcion JS que recibe
run_jses una funcion flecha en texto que toma un objeto con las clavesargs,wasmeiframeWindow, 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
| Tema | Situacion |
|---|---|
| Madurez | Beta declarada. La API cambia entre versiones menores |
| Tamano del bundle | El 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 OTP | Solo las incluidas en el runtime compilado |
| Concurrencia | Los procesos Elixir son reales, pero todos comparten un unico hilo de WebAssembly |
| Acceso al DOM | Siempre a traves de run_js; no hay binding directo |
| Versiones de herramientas | Sensible 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:
| Propiedad | Tipo | Para que sirve |
|---|---|---|
ssid | cadena o binario | Nombre de la red que veran los telefonos |
psk | cadena o binario | Contrasena WPA2. Minimo ocho caracteres; si se omite, la red queda abierta |
ap_channel | entero | Canal WiFi a usar |
ap_ssid_hidden | booleano | Oculta el SSID del anuncio |
ap_max_connections | entero | Limite de clientes asociados |
ap_started | funcion de aridad 0 | Se invoca cuando el AP quedo operativo |
sta_connected | funcion de aridad 1 | Recibe la MAC del cliente que se asocio |
sta_ip_assigned | funcion de aridad 1 | Recibe la IP que el DHCP interno entrego |
sta_disconnected | funcion de aridad 1 | Recibe 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 enPathSuffixel resto del camino ya segmentado: para/api/moverecibe["move"]. - La ruta
{[], ...}es el comodin final: cualquier cosa que no coincida con una ruta anterior la atiendehttpd_file_handler. handler_config: %{app: :robot_ctl}le dice al manejador de archivos que sirva los archivos desde el directorioprivde esa aplicacion dentro del bundle.- El valor de retorno decide la respuesta:
{:ok, mapa}se codifica como JSON conContent-Type: application/json;{:ok, cadena}se devuelve como texto plano;:not_found,:bad_requeste:internal_server_errorproducen los codigos de estado correspondientes. @behaviour :httpd_api_handlerno es decorativo: hace que el compilador te avise si la firma dehandle_api_request/4no coincide.- El mapa
http_requesttrae, entre otras claves,:method,:headers,:bodyy:socket. Con el socket y:socket.peername/1puedes registrar quien envio cada comando.
Las rutas expuestas
| Metodo | Ruta | Cuerpo | Respuesta |
|---|---|---|---|
GET | /index.html | — | La pagina de control desde priv/ |
POST | /api/move | forward, back, left, right, stop | {"status":"ok","direction":"forward"} |
GET | /api/status | — | Plataforma, direccion actual, memoria libre, procesos |
| cualquiera | otra | — | 404 |
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">▲</button>
<button id="left" data-dir="left">◀</button>
<button id="stop" data-dir="stop">STOP</button>
<button id="right" data-dir="right">▶</button>
<button id="back" data-dir="back">▼</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.Motionque 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_connectionsexiste porque el radio del ESP32 no atiende decenas de asociaciones.
Las tres extensiones vistas juntas
| Aspecto | NIF | Port driver | Popcorn | Servidor HTTP en el dispositivo |
|---|---|---|---|---|
| Lenguaje del codigo nuevo | C | C | Elixir + JavaScript | Elixir + HTML/JS |
| Requiere regrabar firmware | Si | Si | No aplica | No |
| Donde se ejecuta | En el chip | En el chip | En el navegador | En el chip, interfaz en el navegador |
| Necesita red | No | No | Si (para servir la pagina) | Si (AP o router) |
| Ciclo de iteracion | Lento | Lento | Rapido | Rapido |
| Riesgo de dejar el chip inutilizable | Alto | Alto | Nulo | Bajo |
| Cuando usarlo | Periferico o calculo no soportado | Periferico con estado o eventos | Panel, simulacion, demo | Teleoperacion, 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
| Sintoma | Causa | Solucion |
|---|---|---|
El firmware compila pero el NIF devuelve :nif_not_loaded | El enlazador descarto el objeto porque nada lo referencia | Agregar el bloque LINK_OPTIONS con -Wl,--whole-archive sobre la biblioteca del componente |
| El componente ni siquiera se compila | ESP-IDF cachea la lista de componentes | Ejecutar idf.py reconfigure antes de idf.py build |
undefined reference a term_from_int o context_new | Falta declarar la dependencia del componente | Agregar PRIV_REQUIRES "libatomvm" "avm_sys" en idf_component_register |
| El NIF nunca se resuelve aunque el codigo esta enlazado | El nombre buscado no coincide | Usar exactamente "Elixir.Modulo.Submodulo:funcion/aridad", con el prefijo Elixir. |
| Valores corruptos o reinicios aleatorios tras construir una tupla | Se reservo memoria despues de tener terminos vivos y la recoleccion los movio | Reservar todo el heap antes de construir, o usar memory_ensure_free_with_roots |
| El chip se reinicia por watchdog al llamar una funcion nativa | El NIF bloquea el unico hilo de la VM | Convertir 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 buzon | Verificar port_parse_gen_message, la construccion de la respuesta y la llamada a mailbox_remove_message |
open_port falla con :badarg | El nombre del driver no coincide con el de REGISTER_PORT_DRIVER | El 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 promesa | La aplicacion Elixir nunca aviso que estaba lista | Llamar 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 process | Pasar { process: "main" } en las opciones, o registrar el proceso con Popcorn.Wasm.ready(:main) |
run_js no cambia nada en la pagina | La funcion JS no devuelve un arreglo, o consulta el DOM del iframe en vez del de la pagina | Devolver siempre un arreglo ([] si no hay valores) y revisar si corresponde usar iframeWindow |
| El telefono se conecta al AP pero no carga la pagina | El navegador uso datos moviles al detectar una red sin Internet | Desactivar 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 mueve | El manejador HTTP escribe pines desde varios procesos, o el puente H no comparte tierra con el ESP32 | Serializar en un GenServer unico y verificar la masa comun con el multimetro |
httpd falla al arrancar o nadie lo alcanza | Se levanto antes de que existiera la interfaz de red | Usar :network.wait_for_ap/2 y continuar solo con :ok |
Los archivos de priv/ dan 404 | El bundle no los incluye o el app del manejador no coincide | Confirmar 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 desconecta | No hay temporizador de seguridad | Detener automaticamente si no llega un comando dentro de una ventana de tiempo |
Ejercicios propuestos
-
Tu primer NIF. Reproduce el ejemplo
hello/0completo: componente,Kconfig,CMakeLists.txt, firmware regrabado y aplicacion Elixir. Despues quita a proposito el bloqueLINK_OPTIONS, recompila y comprueba que el sintoma es exactamente:nif_not_loaded. Documenta el diagnostico en tres lineas. -
NIF con validacion y presupuesto de heap. Escribe
Robot.Math.clamp/3, que acote un valor entre dos limites, lance:badargsi 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 conTUPLE_SIZE(4)y reduce la reserva a proposito para observar el comportamiento en el dispositivo. -
Port driver con eventos. Extiende el driver
contadorcon 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 conport_send_message_from_task. Del lado Elixir, unGenServerque reciba esos mensajes y los imprima. -
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/1el 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. -
Contador en el navegador. Levanta el proyecto Popcorn completo del capitulo. Despues agrega un tercer boton que envie un
popcorn.castcon un valor arbitrario y verifica en la consola que elIO.inspectdel handler te muestra la forma exacta en que llega el dato deserializado. Anota esa forma. -
Simulador de la maquina de estados. Toma
Robot.Motiony crea una version que, en vez de escribir pines, dibuje la direccion actual en el DOM conrun_js. Sirvela con Popcorn. Ahora tienes la misma maquina de estados corriendo en el chip y en el navegador desde el mismo codigo fuente. -
Teleoperacion con temporizador de seguridad. Modifica
Robot.Motionpara 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. -
Endpoint de diagnostico y autenticacion minima. Agrega
GET /api/telemetrycon 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_requestcualquier/api/moveque no lo traiga. Discute por escrito por que ese token sigue siendo insuficiente frente a alguien conectado al mismo AP. -
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/.