Hardware avanzado con la BEAM: Nerves, Elixir Circuits, Soleil, Beam Bots y GRiSP
Hardware avanzado con la BEAM: Nerves, Elixir Circuits, Soleil, Beam Bots y GRiSP
En el capítulo 15 llevamos la BEAM al extremo inferior del espectro: AtomVM ejecutando bytecode de Elixir en un ESP32 con unos cientos de kilobytes de RAM, sin sistema operativo y con un recolector de basura reescrito para caber en ese presupuesto. Ahí la pregunta era cuánto se puede recortar sin perder el modelo de procesos y mensajes.
Este capítulo hace el camino inverso. Sube hasta los dispositivos donde la BEAM corre completa —la de verdad, con su planificador, sus millones de procesos y su OTP entero— y donde el problema ya no es la memoria sino la operación: cómo se construye una imagen de firmware reproducible, cómo se actualiza un aparato que está a 400 kilómetros colgado de un poste, cómo se habla con un sensor sin bloquear el planificador, cómo se sobrevive con una batería y un panel solar, y cómo se modela un robot articulado sin arrastrar el middleware de ROS2.
Son cinco piezas y encajan entre sí. Nerves es el sistema de compilación y el runtime que produce el firmware. Elixir Circuits es la capa que habla con los buses de hardware desde dentro de ese firmware. Soleil es la placa que le da energía y le enseña a dormir. Beam Bots es el modelo de programación para robots articulados encima de OTP. GRiSP es la vía alternativa: la misma BEAM, pero sin Linux debajo.
El mapa: cuatro formas de poner la BEAM sobre hardware
Las cinco tecnologías del capítulo no compiten entre sí: ocupan capas distintas.
graph TB
subgraph AVM["AtomVM — capítulos 13 a 15"]
AV1["ESP32 / STM32 · 320 KB RAM"] --> AV2["Sin SO o FreeRTOS"] --> AV3["AtomVM:<br/>intérprete reducido"]
end
subgraph NRV["Nerves"]
N1["Raspberry Pi · BeagleBone · x86_64"] --> N2["Linux mínimo (Buildroot)<br/>rootfs de solo lectura"] --> N3["BEAM completa + OTP + Circuits"]
end
subgraph GRS["GRiSP"]
G1["GRiSP 2 — iMX6UL Cortex-A7"] --> G2["RTEMS: RTOS de tiempo real"] --> G3["BEAM completa + OTP<br/>+ drivers PMOD"]
end
subgraph SRV["Servidor"]
S1["Linux de propósito general"] --> S2["Phoenix · NervesHub · base de datos"]
end
AV3 -.->|"MQTT / HTTP"| N3
N3 -.->|"NervesHub o distribución de Erlang"| S2
G3 -.->|"distribución de Erlang"| S2
La observación clave es que las cuatro columnas comparten el mismo modelo de programación. Un GenServer que lee un sensor se escribe igual en las cuatro; lo que cambia es qué hay debajo. Esa continuidad es la razón de existir del ecosistema: un equipo escribe el firmware del nodo, el coordinador del edge y el backend de la nube con el mismo lenguaje y el mismo modelo de supervisión.
| Plataforma | Capa bajo la BEAM | RAM típica | Arranque | Actualización remota | Determinismo |
|---|---|---|---|---|---|
| AtomVM | Ninguna o FreeRTOS | 320 KB | menos de 1 s | Manual u OTA propia | Alto |
| Nerves | Linux Buildroot | 512 MB – 8 GB | 2 – 6 s | Nativa: A/B + NervesHub | Medio |
| GRiSP 2 | RTEMS | 128 MB | 1 – 2 s | GRiSP.io | Alto |
| Linux de distribución | Debian, Ubuntu | 1 GB o más | 20 – 60 s | Gestor de paquetes | Bajo |
La última fila está como control. Un Raspberry Pi OS con un servicio de systemd funciona bien en un escritorio, pero en campo trae tres problemas que Nerves elimina por construcción: el sistema de archivos de escritura que se corrompe con un corte de energía, la deriva de configuración entre dispositivos instalados en fechas distintas, y la ausencia de un mecanismo de reversión cuando una actualización sale mal.
Nerves: qué es realmente
La descripción corta —“framework para escribir firmware embebido en Elixir”— esconde lo importante. Nerves no es una biblioteca que se agrega a un proyecto: es un sistema de compilación cruzada que produce una imagen de disco completa y un runtime que gestiona esa imagen en el dispositivo.
Cuando ejecutas mix firmware, el resultado no es un .beam ni un tarball de release: es un archivo .fw con la tabla de particiones, un kernel Linux, un sistema de archivos raíz de solo lectura con tu release dentro, y las instrucciones para escribir todo eso en una tarjeta SD o en la eMMC del aparato.
Anatomía de la imagen y de las particiones
graph LR
subgraph FW[".fw producido por mix firmware"]
M["meta.conf · versión · UUID · firma"]
R["rootfs.img — squashfs de solo lectura"]
K["zImage + device tree + bootloader"]
end
subgraph SD["Medio de almacenamiento del dispositivo"]
P0["Partición de arranque<br/>bootloader · kernel · entorno fwup"]
PA["Partición A — rootfs"]
PB["Partición B — rootfs"]
PD["Partición de datos<br/>lectura/escritura, montada en /data"]
end
K --> P0
M --> P0
R --> PA
R -.->|"la próxima actualización<br/>escribe en la otra"| PB
PA --> BEAM["BEAM ejecuta el release<br/>desde un rootfs inmutable"]
PD --> BEAM
Cuatro decisiones de diseño explican casi todo el comportamiento de Nerves:
- El rootfs es de solo lectura. Está en squashfs y se monta sin permiso de escritura. Un corte de energía en cualquier instante deja el sistema tal como estaba, porque no había nada a medio escribir.
- Hay dos particiones de rootfs, A y B. El firmware que corre vive en una; la actualización se escribe en la otra. Solo cuando la escritura terminó y se verificó se cambia el puntero de arranque.
- Los datos mutables viven en su propia partición, montada en
/data. Es la única parte que se puede escribir y, por lo tanto, la única que puede corromperse. Si se corrompe, se reformatea y el dispositivo sigue arrancando: se pierden datos, no el aparato. - No hay gestor de paquetes ni shell de administración. El sistema se define en el momento de compilar, así que dos dispositivos con la misma versión de firmware son idénticos. Desaparece la clase entera de fallas de “en ese equipo funciona porque alguien instaló algo por SSH hace ocho meses”.
Instalación y primer proyecto
Nerves necesita Elixir, Erlang y utilidades nativas: fwup para escribir imágenes, squashfs-tools para el rootfs y ssh-askpass en Linux para pedir privilegios al grabar.
# Debian / Ubuntu
sudo apt install build-essential automake autoconf git squashfs-tools \
ssh-askpass pkg-config curl libmnl-dev libssl-dev libncurses5-dev
# macOS
brew install fwup squashfs coreutils xz pkg-config
# Arquetipo de proyectos y primer proyecto
mix archive.install hex nerves_bootstrap
mix nerves.new nodo_riego
cd nodo_riego
La estructura resultante agrega cuatro cosas a un proyecto Elixir normal: config/host.exs con la configuración que se aplica al compilar para tu máquina, config/target.exs con la que se aplica al compilar para una placa, el directorio rootfs_overlay/ cuyo contenido se copia dentro de la imagen (ahí vive etc/erlinit.config, que decide qué se ejecuta al arrancar), y un mix.exs con dependencias marcadas por objetivo.
MIX_TARGET: la variable que lo decide todo
export MIX_TARGET=rpi4
mix deps.get
mix firmware
Si MIX_TARGET no está definida vale host y el proyecto compila para tu propia máquina, lo que sirve para pruebas y para trabajar la lógica sin tocar hardware. Cuando apunta a una placa, mix deps.get descarga además el sistema Nerves correspondiente: un paquete precompilado con el toolchain de compilación cruzada, el kernel, el device tree y la configuración de Buildroot para ese hardware.
MIX_TARGET | Hardware | Notas |
|---|---|---|
host | Tu máquina de desarrollo | Pruebas y lógica sin hardware |
rpi0 / rpi0_2 | Raspberry Pi Zero y Zero 2 W | Consumo bajo |
rpi3 / rpi3a | Raspberry Pi 3 y 3A+ | Wi-Fi y Bluetooth integrados |
rpi4 | Raspberry Pi 4 | El objetivo más ejercitado del ecosistema |
rpi5 | Raspberry Pi 5 | GPIO detrás del southbridge RP1 |
bbb | BeagleBone Black y variantes | Incluye los PRU de tiempo real |
grisp2 | GRiSP 2 con Linux | Alternativa al camino RTEMS |
osd32mp1 | Octavo OSD32MP1 (STM32MP1) | Industrial, Cortex-A7 más Cortex-M4 |
x86_64 | PC industrial, mini PC, máquina virtual | Gateways y pruebas de OTA |
Cambiar de MIX_TARGET obliga a volver a resolver dependencias. Es el paso que más se olvida y produce errores de enlazado desconcertantes.
Grabar la primera vez, actualizar todas las demás
mix firmware # produce _build/rpi4_dev/nerves/images/nodo_riego.fw
mix burn # detecta la tarjeta SD y la escribe
mix burn -d /dev/sdX # o se le indica el destino explícito
mix burn se niega a escribir si encuentra más de un medio extraíble, para no destruir un disco por accidente. A partir de la segunda vez, sacar la tarjeta deja de ser necesario: si el dispositivo está en la red y tiene nerves_ssh (incluido en nerves_pack), la actualización viaja por SSH.
mix upload nerves.local
mix upload 192.168.1.42
mix upload transfiere el .fw, lo escribe en la partición inactiva y reinicia. Es la misma mecánica de la actualización OTA de producción, ejercitada desde el escritorio.
El árbol de supervisión de una aplicación Nerves
Aquí termina lo específico de Nerves y empieza Elixir normal. Una aplicación de firmware es una aplicación OTP común: un supervisor raíz y procesos debajo.
defmodule NodoRiego.Application do
@moduledoc false
use Application
@impl true
def start(_type, _args) do
opts = [strategy: :one_for_one, name: NodoRiego.Supervisor]
Supervisor.start_link(children(target()), opts)
end
# En la máquina de desarrollo no hay GPIO ni sensores: solo lo portable.
defp children(:host), do: [{Phoenix.PubSub, name: NodoRiego.PubSub}, NodoRiego.Historial]
# En la placa se levanta el árbol completo.
defp children(_target) do
[
{Phoenix.PubSub, name: NodoRiego.PubSub},
NodoRiego.Historial,
NodoRiego.Conectividad,
NodoRiego.Validador,
{NodoRiego.SensorHumedad, canal: 0, intervalo_ms: 5_000},
{NodoRiego.Valvula, gpio: "GPIO26"},
NodoRiego.Regulador
]
end
defp target, do: Application.get_env(:nodo_riego, :target)
end
Las dependencias se separan entre las que valen en todas partes y las que solo tienen sentido sobre la placa:
defp deps do
[
# Comunes a host y placa
{:nerves, "~> 1.10", runtime: false},
{:shoehorn, "~> 0.9"},
{:ring_logger, "~> 0.11"},
{:toolshed, "~> 0.4"},
{:phoenix_pubsub, "~> 2.1"},
# Solo en la placa
{:nerves_runtime, "~> 0.13", targets: @all_targets},
{:nerves_pack, "~> 0.7", targets: @all_targets},
{:circuits_gpio, "~> 2.1", targets: @all_targets},
{:circuits_i2c, "~> 2.0", targets: @all_targets},
# Sistema Nerves por plataforma
{:nerves_system_rpi4, "~> 1.24", runtime: false, targets: :rpi4}
]
end
La opción targets: es la que evita que circuits_gpio intente compilar su código nativo cuando trabajas en el escritorio. Es la clave para que mix test funcione sin hardware.
En un Linux normal init es systemd y la aplicación es un servicio más. En Nerves init es erlinit, un programa diminuto en C cuyo único trabajo es montar los sistemas de archivos, configurar la consola y ejecutar la BEAM. La BEAM es el proceso 1 y no hay nada más corriendo. Esa decisión trae una política de tolerancia a fallos de última instancia: si el árbol de supervisión completo se derrumba y la BEAM termina, erlinit reinicia el aparato. No queda un dispositivo encendido pero inerte.
Red: VintageNet
La configuración de red no se hace editando archivos del sistema —el rootfs es inmutable— sino declarándola. La biblioteca es VintageNet: cada interfaz tiene un mapa de configuración y VintageNet se encarga de aplicarlo y mantenerlo, reconexión incluida.
# config/target.exs
import Config
config :vintage_net,
regulatory_domain: "CL",
config: [
{"usb0", %{type: VintageNetDirect}},
{"eth0", %{type: VintageNetEthernet, ipv4: %{method: :dhcp}}},
{"wlan0",
%{
type: VintageNetWiFi,
vintage_net_wifi: %{
networks: [
%{
key_mgmt: :wpa_psk,
ssid: System.get_env("NERVES_WIFI_SSID"),
psk: System.get_env("NERVES_WIFI_PSK")
}
]
},
ipv4: %{method: :dhcp}
}}
]
En tiempo de ejecución, VintageNet.configure/2 reemplaza esa configuración sin reiniciar, y el estado se consulta o se observa como propiedades:
VintageNet.get(["interface", "wlan0", "connection"])
#=> :internet
VintageNet.get(["interface", "wlan0", "addresses"])
# Suscripción: llegan mensajes al proceso cuando la propiedad cambia
VintageNet.subscribe(["interface", "wlan0", "connection"])
receive do
{VintageNet, ["interface", "wlan0", "connection"], anterior, nuevo, _meta} ->
IO.puts("wlan0: #{anterior} -> #{nuevo}")
end
Los tres valores de connection describen situaciones que en la práctica hay que distinguir: :disconnected (sin enlace), :lan (hay enlace y dirección, pero no se llega a Internet) e :internet (se verificó salida). Un proceso que publica telemetría debe reaccionar a la transición a :internet, no a la de :lan. Envolver esas suscripciones en un GenServer que mantenga el estado de todas las interfaces y difunda por PubSub un único mensaje {:conectividad, true | false} evita que cada proceso interesado tenga que repetir la misma lógica.
Actualización remota: particiones A/B y validación
Este es el mecanismo que justifica todo el diseño. El estado de una actualización pasa por varias etapas y solo la última es irreversible.
stateDiagram-v2
[*] --> CorriendoA: en producción<br/>partición A activa y validada
CorriendoA --> Escribiendo: llega firmware nuevo<br/>(mix upload o NervesHub)
Escribiendo --> CorriendoA: falla de escritura,<br/>firma inválida o corte de energía
Escribiendo --> MarcandoB: escritura completa y verificada
MarcandoB --> BSinValidar: se apunta el bootloader a B<br/>y se reinicia
BSinValidar --> CorriendoA: el watchdog expira<br/>sin validación → revierte
BSinValidar --> CorriendoB: la aplicación llama a<br/>Nerves.Runtime.validate_firmware/0
CorriendoB --> [*]
note right of BSinValidar
Ventana crítica: el firmware nuevo
corre pero todavía no es de confianza.
Si no se declara sano, el siguiente
arranque vuelve al anterior.
end note
La validación no es automática por una razón deliberada: que el sistema arranque no significa que funcione. Un firmware puede levantar la BEAM correctamente y haber roto la conexión Wi-Fi, con lo que el dispositivo quedaría inalcanzable para siempre. Por eso la aplicación decide cuándo declararse sana.
# Declara sano el firmware recién instalado solo si se cumplen las condiciones
# mínimas de operación. Si no se cumplen dentro del plazo, reinicia y el
# bootloader vuelve al firmware anterior.
defmodule NodoRiego.Validador do
use GenServer
require Logger
@plazo_ms :timer.minutes(3)
@reintento_ms :timer.seconds(10)
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
@impl true
def init(_opts) do
if Nerves.Runtime.firmware_valid?() do
:ignore
else
Process.send_after(self(), :vencido, @plazo_ms)
send(self(), :verificar)
{:ok, %{}}
end
end
@impl true
def handle_info(:verificar, state) do
# Criterios de salud: hay salida a Internet y el sensor responde.
if NodoRiego.Conectividad.en_linea?() and NodoRiego.SensorHumedad.responde?() do
Nerves.Runtime.validate_firmware()
Logger.info("firmware validado")
{:stop, :normal, state}
else
Process.send_after(self(), :verificar, @reintento_ms)
{:noreply, state}
end
end
def handle_info(:vencido, state) do
Logger.error("el firmware no alcanzó estado sano; reiniciando para revertir")
Nerves.Runtime.reboot()
{:noreply, state}
end
end
Nerves.Runtime es la interfaz al estado del dispositivo:
| Función | Qué hace |
|---|---|
Nerves.Runtime.reboot/0 | Reinicia de forma ordenada, cerrando la BEAM primero |
Nerves.Runtime.poweroff/0 | Apaga el sistema |
Nerves.Runtime.validate_firmware/0 | Marca la partición actual como buena |
Nerves.Runtime.firmware_valid?/0 | Indica si la partición actual ya fue validada |
Nerves.Runtime.serial_number/0 | Número de serie del hardware |
Nerves.Runtime.KV.get/1 | Lee una variable del entorno de firmware |
Nerves.Runtime.KV.get_active/1 | Lee una variable de la partición activa |
El almacén clave-valor de Nerves.Runtime.KV vive en la partición de arranque y sobrevive a las actualizaciones. Ahí están la versión del firmware, el UUID de la imagen, cuál partición está activa y los datos de aprovisionamiento grabados en fábrica.
iex> Nerves.Runtime.KV.get("nerves_fw_active")
"a"
iex> Nerves.Runtime.KV.get_active("nerves_fw_version")
"0.4.2"
NervesHub: la flota
mix upload sirve para un dispositivo en la misma red. Para cien o diez mil existe NervesHub, un servicio alojado o autohospedado que gestiona firmware firmado, despliegues por cohortes y consola remota.
sequenceDiagram
participant CI as CI / build
participant NH as NervesHub
participant D as Dispositivo (nerves_hub_link)
CI->>CI: mix firmware y firma del .fw<br/>con la clave privada
CI->>NH: publica el firmware y crea un<br/>despliegue por cohorte (10 %)
D->>NH: WebSocket persistente autenticado<br/>con certificado del dispositivo
NH-->>D: "hay firmware 0.5.0 para ti"
D->>NH: descarga por HTTPS
D->>D: verifica la firma con la clave pública<br/>grabada en el firmware anterior
D->>D: fwup escribe la partición inactiva, reinicia<br/>y el validador comprueba salud
D->>NH: se reconecta reportando 0.5.0
Note over D,NH: si los dispositivos no se reconectan,<br/>el despliegue se detiene solo
Tres piezas de este flujo merecen atención. La identidad del dispositivo es un certificado, no una contraseña compartida: cada aparato tiene su par de claves, idealmente generado dentro de un chip criptográfico que nunca expone la privada. La firma del firmware se verifica en el dispositivo, con una clave pública que viajó dentro del firmware anterior, así que comprometer el servidor de distribución no basta para instalar código arbitrario. Y el despliegue es progresivo y se detiene solo: si los dispositivos de la primera cohorte dejan de reconectarse, el sistema no continúa. Es la única defensa práctica contra un firmware que deja mudo al aparato, porque limita cuántos se rompen antes de que alguien se dé cuenta.
Del lado del dispositivo la integración es una dependencia, {:nerves_hub_link, "~> 2.5", targets: @all_targets}, y un bloque config :nerves_hub_link que fija el servidor, el configurador de identidad, las claves públicas con las que se verifica la firma del firmware y si se habilita la consola remota.
Datos que deben sobrevivir
Todo lo que la aplicación escriba tiene que ir a la partición de datos; escribir en cualquier otro lugar produce un error de sistema de archivos de solo lectura. :dets viene con OTP, no requiere dependencias nativas y basta para un registro histórico modesto.
defmodule NodoRiego.Historial do
use GenServer
@tabla :historial_riego
def start_link(_), do: GenServer.start_link(__MODULE__, [], name: __MODULE__)
def registrar(hum, regando?), do: GenServer.cast(__MODULE__, {:registrar, hum, regando?})
def sincronizar, do: GenServer.call(__MODULE__, :sincronizar)
@impl true
def init(_) do
# En la placa, /data; en el escritorio, un directorio temporal.
dir =
if Application.get_env(:nodo_riego, :target) == :host,
do: Path.join(System.tmp_dir!(), "nodo_riego"),
else: "/data/nodo_riego"
File.mkdir_p!(dir)
ruta = dir |> Path.join("historial.dets") |> String.to_charlist()
{:ok, @tabla} = :dets.open_file(@tabla, file: ruta, type: :set)
{:ok, %{}}
end
@impl true
def handle_cast({:registrar, hum, regando?}, s) do
:dets.insert(@tabla, {System.system_time(:second), hum, regando?})
{:noreply, s}
end
@impl true
def handle_call(:sincronizar, _from, s), do: {:reply, :dets.sync(@tabla), s}
def terminate(_razon, _s), do: :dets.close(@tabla)
end
Toolshed: la consola del dispositivo
Conectarse por SSH a un dispositivo Nerves deja directamente en una sesión IEx: no hay shell de Unix. Para no quedar sin herramientas, toolshed agrega comandos que se importan solos en la sesión —ifconfig, ping, top, uname, uptime, cmd "ls -la /data"— y log_attach engancha el registro a la consola actual. Ese registro es RingLogger: en un dispositivo embebido no conviene escribir a disco porque desgasta la flash, así que Nerves usa un búfer circular en memoria que se consulta bajo demanda con RingLogger.next/0.
Elixir Circuits: hablar con el hardware
Nerves produce el sistema; Elixir Circuits son las bibliotecas que hablan con los buses. Son cuatro paquetes independientes, uno por bus, con una filosofía común: exponer la operación cruda, sin abstracciones de sensores, y dejar que los drivers de dispositivo se construyan encima.
graph LR
APP["Tu aplicación · GenServers supervisados"] --> GPIO["circuits_gpio"] & I2C["circuits_i2c"] & SPI["circuits_spi"] & UART["circuits_uart"]
GPIO --> CDEV["/dev/gpiochipN"]
I2C --> SYSI2C["/dev/i2c-N"]
SPI --> SYSSPI["/dev/spidevX.Y"]
UART --> TTY["/dev/ttyUSB* · /dev/ttyAMA*"]
GPIO & I2C & SPI -.->|"backend de pruebas"| SIM["CircuitsSim<br/>hardware simulado en memoria"]
CDEV & SYSI2C & SYSSPI & TTY --> HW["Sensores · actuadores · pantallas"]
Un detalle de implementación explica buena parte del comportamiento: cada biblioteca ejecuta el acceso al hardware en un puerto o en un NIF con planificador sucio, no en el planificador normal de la BEAM. Una lectura I2C que tarda 30 ms no bloquea a los demás procesos. Es la diferencia entre un sistema que sigue respondiendo mientras un sensor lento contesta y uno que se congela entero.
GPIO
{:circuits_gpio, "~> 2.1"}
La versión 2.x cambió la forma de identificar líneas: en vez de un número suelto se usa una especificación, que puede ser el nombre de la línea o la tupla de controlador y desplazamiento.
iex> Circuits.GPIO.enumerate() |> Enum.take(3)
[
%{location: {"gpiochip0", 0}, label: "ID_SDA", controller: "pinctrl-bcm2835"},
%{location: {"gpiochip0", 1}, label: "ID_SCL", controller: "pinctrl-bcm2835"},
%{location: {"gpiochip0", 2}, label: "SDA1", controller: "pinctrl-bcm2835"}
]
# Salida: encender un LED
{:ok, led} = Circuits.GPIO.open("GPIO18", :output, initial_value: 0)
Circuits.GPIO.write(led, 1)
Circuits.GPIO.close(led)
# Entrada con resistencia de pull-up interna
{:ok, boton} = Circuits.GPIO.open("GPIO23", :input, pull_mode: :pullup)
Circuits.GPIO.read(boton)
#=> 1 (sin pulsar; devuelve 0 al pulsar si el botón va a tierra)
Lo verdaderamente útil son las interrupciones: en vez de consultar el pin en un bucle, el sistema envía un mensaje al proceso cuando la línea cambia.
defmodule NodoRiego.Boton do
use GenServer
@rebote_ms 40
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
@impl true
def init(opts) do
{:ok, h} = Circuits.GPIO.open(Keyword.get(opts, :gpio, "GPIO23"), :input, pull_mode: :pullup)
:ok = Circuits.GPIO.set_interrupts(h, :both)
{:ok, %{handle: h, ultimo_ns: 0, estado: Circuits.GPIO.read(h)}}
end
# Mensaje de circuits_gpio 2.x: {:circuits_gpio, spec, timestamp_ns, valor}
@impl true
def handle_info({:circuits_gpio, _spec, ts_ns, valor}, s) do
cond do
div(ts_ns - s.ultimo_ns, 1_000_000) < @rebote_ms ->
{:noreply, s}
valor == 0 and s.estado == 1 ->
Phoenix.PubSub.broadcast(NodoRiego.PubSub, "entrada", {:boton, :pulsado})
{:noreply, %{s | ultimo_ns: ts_ns, estado: valor}}
true ->
{:noreply, %{s | ultimo_ns: ts_ns, estado: valor}}
end
end
end
La marca de tiempo viene del kernel, en nanosegundos desde el arranque monótono. Usarla en vez de System.monotonic_time/0 da un antirrebote más preciso, porque mide cuándo ocurrió el flanco y no cuándo el proceso alcanzó a leer el mensaje.
I2C
iex> Circuits.I2C.bus_names()
["i2c-1"]
iex> {:ok, bus} = Circuits.I2C.open("i2c-1")
iex> Circuits.I2C.detect_devices(bus)
[0x48, 0x68, 0x76]
La operación que usan casi todos los sensores es write_read/4: escribir la dirección de un registro y leer la respuesta en una sola transacción atómica. Este driver del sensor BMP280 la ejercita completa, incluida la lectura de los coeficientes de calibración de fábrica.
# Driver mínimo para el BMP280 en I2C: verifica el identificador del chip, lee
# los coeficientes de calibración, configura el muestreo y compensa la lectura
# cruda de temperatura según el procedimiento del fabricante.
defmodule NodoRiego.BMP280 do
use GenServer
@direccion 0x76
@reg_id 0xD0
@reg_calib 0x88
@reg_control 0xF4
@reg_datos 0xF7
@id_esperado 0x58
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
def leer, do: GenServer.call(__MODULE__, :leer)
@impl true
def init(opts) do
{:ok, bus} = Circuits.I2C.open(Keyword.get(opts, :bus, "i2c-1"))
with {:ok, <<@id_esperado>>} <- Circuits.I2C.write_read(bus, @direccion, <<@reg_id>>, 1),
{:ok, calib} <- Circuits.I2C.write_read(bus, @direccion, <<@reg_calib>>, 6),
# osrs_t = 1, osrs_p = 1, modo normal
:ok <- Circuits.I2C.write(bus, @direccion, <<@reg_control, 0x27>>) do
{:ok, %{bus: bus, calib: decodificar(calib)}}
else
{:ok, otro} -> {:stop, {:chip_desconocido, otro}}
error -> {:stop, error}
end
end
@impl true
def handle_call(:leer, _from, state) do
case Circuits.I2C.write_read(state.bus, @direccion, <<@reg_datos>>, 6) do
{:ok, <<_p1, _p2, _p3, t1, t2, t3::size(4), _::size(4)>>} ->
crudo = t1 * 4096 + t2 * 16 + t3
{:reply, {:ok, %{temperatura_c: compensar(crudo, state.calib)}}, state}
error ->
{:reply, error, state}
end
end
# Coeficientes de 16 bits en little endian: el primero sin signo, los otros con signo.
defp decodificar(<<t1::little-unsigned-16, t2::little-signed-16, t3::little-signed-16>>) do
%{t1: t1, t2: t2, t3: t3}
end
defp compensar(crudo, c) do
var1 = (crudo / 16_384.0 - c.t1 / 1_024.0) * c.t2
var2 = :math.pow(crudo / 131_072.0 - c.t1 / 8_192.0, 2) * c.t3
Float.round((var1 + var2) / 5_120.0, 2)
end
end
Este ejemplo muestra por qué Circuits no incluye drivers de sensores: la compensación del BMP280 sale de su datasheet y no se parece a la de ningún otro chip. Circuits garantiza el transporte; el driver es tuyo o de una biblioteca dedicada.
SPI
SPI es full duplex: cada byte que se envía produce un byte que se recibe, y la API lo refleja con una sola operación de transferencia.
# Lee canales analógicos del conversor MCP3008 por SPI. El protocolo son tres
# bytes: bit de inicio, modo y canal, y un byte de relleno; la respuesta trae
# 10 bits útiles repartidos entre los dos últimos bytes recibidos.
defmodule NodoRiego.MCP3008 do
import Bitwise
def abrir(bus \\ "spidev0.0"), do: Circuits.SPI.open(bus, speed_hz: 1_000_000, mode: 0)
@spec leer(reference(), 0..7) :: {:ok, 0..1023} | {:error, term()}
def leer(ref, canal) when canal in 0..7 do
case Circuits.SPI.transfer(ref, <<0x01, bsl(bor(0x08, canal), 4), 0x00>>) do
{:ok, <<_ignorado, alto::size(2), _::size(6), bajo>>} -> {:ok, alto * 256 + bajo}
error -> error
end
end
def a_voltios(crudo, vref \\ 3.3), do: Float.round(crudo * vref / 1023.0, 3)
end
El import Bitwise habilita bor/2 y bsl/2 como funciones sueltas. La opción mode: corresponde a los cuatro modos de reloj de SPI, que combinan polaridad y fase: usar el modo equivocado es la causa número uno de lecturas que devuelven siempre cero o siempre 0xFF.
UART
circuits_uart es la más antigua de la familia y la única que usa un proceso en vez de una referencia. Su característica distintiva es el framing: decirle cómo vienen delimitados los mensajes para recibirlos completos en vez de fragmentos arbitrarios.
defmodule NodoRiego.PuenteSerie do
use GenServer
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
def enviar(comando), do: GenServer.call(__MODULE__, {:enviar, comando})
@impl true
def init(opts) do
{:ok, uart} = Circuits.UART.start_link()
puerto = Keyword.get(opts, :puerto, "ttyUSB0")
framing = {Circuits.UART.Framing.Line, separator: "\r\n"}
:ok = Circuits.UART.open(uart, puerto, speed: 115_200, active: true, framing: framing)
{:ok, %{uart: uart}}
end
@impl true
def handle_call({:enviar, cmd}, _from, s), do: {:reply, Circuits.UART.write(s.uart, cmd), s}
# Con framing de línea llega una línea completa por mensaje.
@impl true
def handle_info({:circuits_uart, _p, {:error, razon}}, s), do: {:stop, {:uart, razon}, s}
def handle_info({:circuits_uart, _p, linea}, s) when is_binary(linea) do
Phoenix.PubSub.broadcast(NodoRiego.PubSub, "serie", {:linea, linea})
{:noreply, s}
end
end
Para descubrir qué hay conectado, Circuits.UART.enumerate/0 devuelve un mapa de puertos con fabricante, identificador de producto y descripción cuando el dispositivo los publica.
Probar sin hardware: CircuitsSim
CircuitsSim implementa los mismos backends de GPIO, I2C y SPI sobre dispositivos simulados en memoria. Sirve para desarrollar cuando la placa no está en el escritorio y, sobre todo, para escribir pruebas automatizadas de los drivers.
# mix.exs: {:circuits_sim, "~> 0.1", only: [:dev, :test], targets: :host}
# config/host.exs
config :circuits_i2c, default_backend: CircuitsSim.I2C.Backend
config :circuits_gpio, default_backend: CircuitsSim.GPIO.Backend
De ahí sale la regla que conviene seguir siempre en firmware: separar la lógica de la entrada/salida, de modo que la compensación, los umbrales y las máquinas de estado sean funciones puras que se prueban en el escritorio, y que el acceso al bus quede confinado a un proceso delgado.
Para probar hardware sin crear un proyecto existe además circuits_quickstart: firmware Nerves ya compilado con todas las bibliotecas de Circuits incluidas. Se graba, se entra por SSH a una sesión IEx y Circuits.GPIO, Circuits.I2C y Circuits.SPI están disponibles de inmediato. Es la forma más rápida de responder “¿el sensor está bien conectado?” antes de escribir código propio.
Soleil: energía, sueño y confianza
Un Raspberry Pi conectado a la red eléctrica no tiene problemas de energía. Uno instalado en un invernadero, en una boya o en un poste, sí. Soleil es una placa de gestión de energía y control de suspensión para Raspberry Pi, diseñada por Protolux y pensada explícitamente para Nerves.
| Función de Soleil | Problema que resuelve |
|---|---|
| Batería recargable | El nodo debe seguir funcionando sin red eléctrica |
| Carga por USB-C y por panel solar | Reponer energía en el lugar, con o sin sol |
| Modo de bajo consumo con corte de energía al Pi | Un Pi encendido de forma permanente agota cualquier batería razonable |
| Tres fuentes de despertado | El nodo debe volver a la vida por tiempo, por acción humana o por un evento externo |
| Chip ATECC608A (NervesKey) | Identidad criptográfica del dispositivo frente a NervesHub |
| Conector Qwiic, desde la versión 0.2 | Encadenar sensores I2C sin soldar |
Por qué el sueño lo cambia todo
La aritmética es contundente. Un Raspberry Pi Zero 2 W en reposo consume del orden de 0,4 W y con Wi-Fi transmitiendo puede pasar de 1,5 W. Con una batería de 3000 mAh a 3,7 V —unos 11 Wh útiles— y sin dormir, el nodo dura menos de un día.
| Estrategia | Ciclo de trabajo | Consumo medio | Autonomía con 11 Wh |
|---|---|---|---|
| Siempre encendido | 100 % | ~0,6 W | ~18 horas |
| Despierta 2 min cada hora | 3,3 % | ~40 mW | ~11 días |
| Despierta 1 min cada 6 horas | 0,3 % | ~8 mW | ~2 meses |
El salto entre la primera y la segunda fila es de dos órdenes de magnitud y no se consigue optimizando código: se consigue cortando la alimentación del Pi, que no tiene un modo de sueño profundo comparable al de un ESP32. Eso es exactamente lo que hace Soleil: mantiene encendido solo su propio microcontrolador de bajo consumo, que vigila el reloj y las entradas de despertado, y devuelve la energía al Pi cuando corresponde.
stateDiagram-v2
[*] --> Apagado
Apagado --> Arrancando: fuente de despertado activa<br/>(temporizador, pulsador o señal externa)
Arrancando --> Midiendo: Linux y BEAM listos (2 a 6 s)
Midiendo --> Publicando: hay conectividad
Midiendo --> Almacenando: sin conectividad
Publicando --> Programando
Almacenando --> Programando
Midiendo --> Emergencia: batería bajo el umbral crítico
Emergencia --> Apagado: apagado inmediato, sin publicar
Programando --> Apagado: se informa a la placa cuándo encender,<br/>Nerves.Runtime.poweroff/0 y corte de alimentación
note right of Programando
Paso crítico: si el nodo se apaga
sin haber programado el despertar,
no vuelve nunca.
end note
El apagado seguro, en código
La secuencia correcta importa: primero se anuncia a los demás procesos que el sistema se va a apagar, luego se les da un plazo para cerrar, después se sincroniza la partición de datos, luego se programa el despertar en la placa de energía y solo al final se apaga.
# Coordina el ciclo despertar - trabajar - dormir de un nodo a batería. Los
# registros concretos que se escriben en la placa dependen del modelo, así que
# ese detalle queda aislado en `NodoRiego.PlacaEnergia`.
defmodule NodoRiego.CicloEnergia do
use GenServer
require Logger
@tiempo_maximo_despierto :timer.minutes(4)
@plazo_cierre :timer.seconds(10)
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
@doc "Impide el apagado hasta que el proceso llamador libere el permiso."
def retener(motivo), do: GenServer.call(__MODULE__, {:retener, motivo, self()})
def liberar, do: GenServer.call(__MODULE__, {:liberar, self()})
@impl true
def init(opts) do
# Red de seguridad: pase lo que pase, no se queda despierto para siempre.
Process.send_after(self(), :dormir, @tiempo_maximo_despierto)
{:ok, %{intervalo_s: Keyword.get(opts, :intervalo_s, 3_600), retenciones: %{}}}
end
@impl true
def handle_call({:retener, motivo, pid}, _from, s) do
{:reply, :ok, put_in(s.retenciones[pid], {motivo, Process.monitor(pid)})}
end
def handle_call({:liberar, pid}, _from, s) do
s = soltar(s, pid)
if map_size(s.retenciones) == 0, do: send(self(), :dormir)
{:reply, :ok, s}
end
# Un proceso que muere sin liberar no puede dejar el nodo despierto.
@impl true
def handle_info({:DOWN, _ref, :process, pid, _razon}, s), do: {:noreply, soltar(s, pid)}
def handle_info(:dormir, s) when map_size(s.retenciones) > 0 do
Process.send_after(self(), :dormir, :timer.seconds(15))
{:noreply, s}
end
def handle_info(:dormir, s) do
Phoenix.PubSub.broadcast(NodoRiego.PubSub, "energia", :apagando)
Process.sleep(@plazo_cierre)
:ok = NodoRiego.Historial.sincronizar()
:ok = NodoRiego.PlacaEnergia.programar_despertar(s.intervalo_s)
Logger.info("apagando; próximo despertar en #{s.intervalo_s} s")
Nerves.Runtime.poweroff()
{:noreply, s}
end
defp soltar(s, pid) do
case Map.pop(s.retenciones, pid) do
{nil, _} -> s
{{_motivo, ref}, resto} ->
Process.demonitor(ref, [:flush])
%{s | retenciones: resto}
end
end
end
El patrón de retención evita el error clásico de los nodos que duermen: apagarse justo mientras se subía un lote de mediciones. Cualquier proceso a mitad de una operación que no debe interrumpirse pide una retención; el coordinador no apaga hasta que todas se liberen o hasta que expire el plazo máximo, que existe para que un proceso colgado no deje el nodo despierto gastando batería.
NervesKey: la identidad en silicio
El ATECC608A que Soleil incorpora es un elemento seguro: genera un par de claves dentro del chip y nunca deja salir la clave privada. Las firmas ocurren dentro del silicio, así que un atacante con acceso completo al sistema de archivos no puede clonar la identidad del dispositivo. La biblioteca nerves_key habla con ese chip por I2C y lo integra con la autenticación de NervesHub.
# mix.exs: {:nerves_key, "~> 1.2", targets: @all_targets}
iex> {:ok, i2c} = Circuits.I2C.open("i2c-1")
iex> NervesKey.detected?(i2c)
true
Con configurator: NervesHubLink.Configurator.NervesKey en la configuración de nerves_hub_link, el certificado del dispositivo se toma del chip en cada conexión y la clave privada nunca aparece en la memoria de la BEAM. Hay una consecuencia operativa que conviene entender desde el principio: la identidad queda ligada a la placa, no a la tarjeta SD. Cambiar la tarjeta conserva el dispositivo; cambiar la placa crea uno nuevo, aunque conserve el número de serie de la etiqueta.
Beam Bots: OTP como middleware robótico
Un robot articulado no es una colección de motores: es una estructura con jerarquía. Hay eslabones rígidos (links) unidos por articulaciones (joints), y mover una articulación mueve todo lo que cuelga de ella. El software tiene que representar esa estructura, distribuir órdenes a través de ella y leer el estado que vuelve.
En el mundo de C++ y Python ese trabajo lo hace ROS2: un middleware con publicación/suscripción, servicios síncronos, acciones de larga duración, nodos con ciclo de vida y un servidor de parámetros. La observación que da origen a Beam Bots es que la BEAM ya tiene todo eso, y lo tiene desde hace décadas.
| Concepto de ROS2 | Equivalente nativo en la BEAM |
|---|---|
| Tópicos con publicación/suscripción | Phoenix.PubSub, Registry con claves duplicadas, mensajes de Erlang |
| Servicios de petición/respuesta síncronos | GenServer.call/3, con su plazo de espera incorporado |
| Acciones de larga duración con avance parcial | Task.Supervisor sobre Task, con mensajes de progreso al llamador |
| Nodos con ciclo de vida y transiciones | GenServer con máquina de estados explícita, o :gen_statem |
| Servidor de parámetros en tiempo de ejecución | ETS o :persistent_term |
| Descubrimiento de nodos entre máquinas | Distribución de Erlang y :global |
| Reinicio de un nodo caído | Árboles de supervisión de OTP |
| Grabación y reproducción de sesiones | :dets, :disk_log o una tabla en la partición de datos |
La diferencia no es solo de dependencias. En ROS2, un nodo que muere deja al sistema en un estado que hay que detectar y reparar desde fuera. En OTP, la supervisión es la misma que ya usas para todo lo demás: la articulación que falla se reinicia con una política declarada y su supervisor decide si eso implica reiniciar el brazo entero.
La topología: eslabones y articulaciones
graph TD
B["link :base — fijo al suelo"] -->|"joint :hombro · revoluta · eje Z<br/>±180° · 12 N·m · 2,0 rad/s"| E1["link :eslabon_1 — 0,25 m"]
E1 -->|"joint :codo · revoluta · eje Z<br/>±150° · 8 N·m · 3,0 rad/s"| E2["link :eslabon_2 — 0,20 m"]
E2 -->|"joint :muneca · revoluta · eje Z<br/>±90° · 2 N·m · 4,0 rad/s"| EF["link :efector — pinza"]
subgraph PROC["Un proceso GenServer por articulación"]
PH["Joint :hombro"]
PC["Joint :codo"]
PM["Joint :muneca"]
end
PH & PC & PM -.->|"publican su posición"| BUS["PubSub · estado_articulaciones"]
BUS -.-> CIN["Cinemática directa<br/>calcula la pose del efector"]
Cada articulación se describe con cuatro cosas. El tipo: revolute gira alrededor de un eje con límites angulares, continuous gira sin límite (una rueda) y prismatic desliza a lo largo de un eje. El eje, un vector unitario que dice alrededor de qué gira o a lo largo de qué desliza. El padre y el hijo, es decir qué eslabón queda fijo y cuál se mueve. Y los límites: posición mínima y máxima en radianes, esfuerzo máximo en newton-metro y velocidad máxima en radianes por segundo. Esos límites no son decoración: son el contrato entre el software y la mecánica. Un comando que pida más par del que el servomotor puede entregar produce, en el mejor caso, un error de seguimiento y, en el peor, un engranaje partido.
Un DSL declarativo con macros
Beam Bots construye la descripción del robot con macros, de manera que el código se parezca a la estructura física. Vale la pena ver cómo se hace, porque la técnica es la misma que usan Ecto, Phoenix y Plug.
# DSL declarativo para describir la estructura de un robot: acumula eslabones y
# articulaciones en atributos de módulo y genera funciones de consulta en
# tiempo de compilación.
defmodule Robotica.Topologia do
defmacro __using__(_opts) do
quote do
import Robotica.Topologia, only: [robot: 2, link: 1, link: 2, joint: 2]
Module.register_attribute(__MODULE__, :links, accumulate: true)
Module.register_attribute(__MODULE__, :joints, accumulate: true)
@before_compile Robotica.Topologia
end
end
defmacro robot(nombre, do: cuerpo) do
quote do
@robot_nombre unquote(nombre)
unquote(cuerpo)
end
end
defmacro link(nombre, opciones \\ []) do
quote do: @links({unquote(nombre), unquote(opciones)})
end
defmacro joint(nombre, opciones) do
quote do: @joints({unquote(nombre), unquote(opciones)})
end
defmacro __before_compile__(_env) do
quote do
def nombre, do: @robot_nombre
def links, do: Enum.reverse(@links)
def joints, do: Enum.reverse(@joints)
def joint(nombre), do: Enum.find(joints(), fn {n, _opts} -> n == nombre end)
end
end
end
defmodule Robotica.BrazoDidactico do
use Robotica.Topologia
robot "brazo_didactico" do
link :base
link :eslabon_1, longitud_m: 0.25, masa_kg: 0.40
link :eslabon_2, longitud_m: 0.20, masa_kg: 0.25
link :efector, longitud_m: 0.06, masa_kg: 0.10
joint :hombro,
tipo: :revolute, padre: :base, hijo: :eslabon_1, eje: {0.0, 0.0, 1.0},
limites: [posicion_rad: {-3.14, 3.14}, esfuerzo_nm: 12.0, velocidad_rad_s: 2.0]
joint :codo,
tipo: :revolute, padre: :eslabon_1, hijo: :eslabon_2, eje: {0.0, 0.0, 1.0},
limites: [posicion_rad: {-2.62, 2.62}, esfuerzo_nm: 8.0, velocidad_rad_s: 3.0]
joint :muneca,
tipo: :revolute, padre: :eslabon_2, hijo: :efector, eje: {0.0, 0.0, 1.0},
limites: [posicion_rad: {-1.57, 1.57}, esfuerzo_nm: 2.0, velocidad_rad_s: 4.0]
end
end
Todo eso se resuelve en tiempo de compilación: Robotica.BrazoDidactico.joints/0 no recorre ninguna estructura en tiempo de ejecución, devuelve una lista literal que el compilador ya construyó.
La articulación como proceso
Cada articulación es un GenServer con posición actual, objetivo y los límites que nunca debe violar. En modo simulación integra su posición hacia el objetivo respetando la velocidad máxima; en modo hardware, la misma lógica escribe al driver del servomotor.
defmodule Robotica.Joint do
use GenServer
require Logger
@periodo_ms 20
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: via(Keyword.fetch!(opts, :nombre)))
end
def mover(nombre, objetivo_rad), do: GenServer.call(via(nombre), {:mover, objetivo_rad})
def posicion(nombre), do: GenServer.call(via(nombre), :posicion)
def detener(nombre), do: GenServer.call(via(nombre), :detener)
defp via(nombre), do: {:via, Registry, {Robotica.Registro, {:joint, nombre}}}
@impl true
def init(opts) do
limites = Keyword.fetch!(opts, :limites)
inicial = Keyword.get(opts, :posicion_inicial, 0.0)
{min, max} = Keyword.fetch!(limites, :posicion_rad)
:timer.send_interval(@periodo_ms, :tick)
{:ok,
%{
nombre: Keyword.fetch!(opts, :nombre),
posicion: inicial,
objetivo: inicial,
min: min,
max: max,
vel_max: Keyword.fetch!(limites, :velocidad_rad_s),
modo: Keyword.get(opts, :modo, :simulacion)
}}
end
@impl true
def handle_call({:mover, objetivo}, _from, e) do
recortado = min(max(objetivo, e.min), e.max)
if recortado != objetivo do
Logger.warning("#{e.nombre}: objetivo fuera de límites; recortado a #{recortado}")
end
{:reply, {:ok, recortado}, %{e | objetivo: recortado}}
end
def handle_call(:posicion, _from, e), do: {:reply, e.posicion, e}
def handle_call(:detener, _from, e), do: {:reply, :ok, %{e | objetivo: e.posicion}}
@impl true
def handle_info(:tick, e) do
paso_max = e.vel_max * @periodo_ms / 1000.0
error = e.objetivo - e.posicion
nueva =
cond do
abs(error) <= paso_max -> e.objetivo
error > 0 -> e.posicion + paso_max
true -> e.posicion - paso_max
end
if nueva != e.posicion do
aplicar(e.modo, e.nombre, nueva)
Phoenix.PubSub.broadcast(Robotica.PubSub, "articulaciones", {:posicion, e.nombre, nueva})
end
{:noreply, %{e | posicion: nueva}}
end
# En simulación no hay hardware: el estado interno es la verdad.
defp aplicar(:simulacion, _nombre, _posicion), do: :ok
# En modo hardware, aquí va la escritura al driver del servomotor.
defp aplicar(:hardware, nombre, posicion), do: Robotica.Driver.escribir(nombre, posicion)
end
Comandos: comportamientos como módulos
Un comando es una unidad de comportamiento —mover a una pose, recorrer una trayectoria, cerrar la pinza— implementada como un módulo con un contrato común, lo que permite registrarlos, componerlos y ejecutarlos de forma uniforme.
defmodule Robotica.Comando do
@callback validar(params :: map()) :: :ok | {:error, term()}
@callback ejecutar(params :: map()) :: {:ok, term()} | {:error, term()}
end
defmodule Robotica.Comandos.IrAPose do
@behaviour Robotica.Comando
@tolerancia_rad 0.01
@plazo_ms 8_000
@impl true
def validar(%{pose: pose}) when is_map(pose) and map_size(pose) > 0 do
conocidas = Enum.map(Robotica.BrazoDidactico.joints(), &elem(&1, 0))
case Enum.reject(Map.keys(pose), &(&1 in conocidas)) do
[] -> :ok
desconocidas -> {:error, {:articulaciones_desconocidas, desconocidas}}
end
end
def validar(_), do: {:error, :parametros_invalidos}
@impl true
def ejecutar(%{pose: pose}) do
Enum.each(pose, fn {nombre, obj} -> Robotica.Joint.mover(nombre, obj) end)
esperar(pose, System.monotonic_time(:millisecond))
end
defp esperar(pose, inicio) do
en_tolerancia? =
Enum.all?(pose, fn {n, obj} -> abs(Robotica.Joint.posicion(n) - obj) <= @tolerancia_rad end)
cond do
en_tolerancia? -> {:ok, :en_pose}
System.monotonic_time(:millisecond) - inicio > @plazo_ms -> {:error, :plazo_agotado}
true -> Process.sleep(20) and esperar(pose, inicio)
end
end
end
Cinemática directa: de ángulos a posición
Para una cadena plana de articulaciones que giran en el mismo eje, calcular dónde está la punta del brazo se reduce a una suma acumulada de ángulos.
defmodule Robotica.Cinematica do
@doc """
Devuelve `{x, y, orientacion_rad}` del extremo de la cadena. `segmentos`
es una lista de `{angulo_rad, longitud_m}` desde la base hacia el efector.
"""
@spec directa([{float(), float()}]) :: {float(), float(), float()}
def directa(segmentos) do
{x, y, theta} =
Enum.reduce(segmentos, {0.0, 0.0, 0.0}, fn {angulo, longitud}, {x, y, theta} ->
acumulado = theta + angulo
{x + longitud * :math.cos(acumulado), y + longitud * :math.sin(acumulado), acumulado}
end)
{Float.round(x, 4), Float.round(y, 4), Float.round(theta, 4)}
end
end
Verificación en iex -S mix, con el brazo extendido y luego doblado en el codo:
iex> Robotica.Cinematica.directa([{0.0, 0.25}, {0.0, 0.20}, {0.0, 0.06}])
{0.51, 0.0, 0.0}
iex> Robotica.Cinematica.directa([{0.0, 0.25}, {:math.pi() / 2, 0.20}, {0.0, 0.06}])
{0.25, 0.26, 1.5708}
El árbol completo y la sesión de prueba
El supervisor raíz levanta el Registry, el Phoenix.PubSub y un proceso Robotica.Joint por cada articulación declarada, generando las especificaciones a partir de Robotica.BrazoDidactico.joints/0 con Supervisor.child_spec/2 e identificadores {:joint, nombre}. La topología deja de ser documentación y pasa a ser la fuente del árbol de procesos.
iex> Robotica.Joint.mover(:hombro, 0.8)
{:ok, 0.8}
iex> Robotica.Joint.posicion(:hombro)
0.32 # todavía en movimiento: la velocidad está limitada
iex> Process.sleep(500); Robotica.Joint.posicion(:hombro)
0.8
iex> Robotica.Joint.mover(:codo, 99.0)
# [warning] codo: objetivo fuera de límites; recortado a 2.62
{:ok, 2.62}
iex> Robotica.Comandos.IrAPose.ejecutar(%{pose: %{hombro: 0.0, codo: 0.0, muneca: 0.0}})
{:ok, :en_pose}
sequenceDiagram
participant U as Cliente (IEx, API, botón)
participant C as Comando IrAPose
participant JH as Joint :hombro
participant JC as Joint :codo
participant PS as PubSub → cinemática
U->>C: ejecutar(%{pose: %{hombro: 0.8, codo: -0.5}})
C->>C: validar/1 contra la topología
C->>JH: mover(:hombro, 0.8)
C->>JC: mover(:codo, -0.5)
loop cada 20 ms
JH->>JH: integra posición a velocidad limitada
JH->>PS: {:posicion, :hombro, x}
JC->>PS: {:posicion, :codo, y}
end
C->>JH: posicion(:hombro) hasta entrar en tolerancia
C-->>U: {:ok, :en_pose}
Note over C,JC: si una articulación muere, el supervisor la reinicia<br/>y el comando termina en {:error, :plazo_agotado}
El modo simulación no es un juguete: es donde se prueban trayectorias, límites y comportamiento ante fallas sin arriesgar mecánica. Que la única diferencia entre simulación y hardware sea la función aplicar/3 es lo que hace que esas pruebas valgan.
GRiSP: Erlang sobre metal desnudo
Todo lo anterior asume un Linux debajo. GRiSP elimina esa capa: es una plataforma de hardware diseñada para ejecutar la máquina virtual de Erlang sobre RTEMS, un sistema operativo de tiempo real, sin distribución de Linux, sin systemd y sin espacio de usuario que administrar.
| Componente de GRiSP 2 | Especificación |
|---|---|
| Procesador | NXP i.MX6UL, ARM Cortex-A7 a 696 MHz |
| Caché | 128 KB de nivel 2 |
| Memoria | 128 MB DDR3 |
| Almacenamiento | 4 GB eMMC, más ranura microSD |
| Criptografía | Motor por hardware AES, TDES y SHA; arranque seguro |
| Conectividad | Wi-Fi, Ethernet, USB |
| Expansión | Conectores PMOD (SPI, I2C, UART, GPIO) y zócalo mikroBUS |
| Formato | Sistema en módulo sobre placa base ampliable |
La arquitectura de módulo sobre placa base está pensada para producto: el prototipo se hace con la placa base de desarrollo y sus conectores PMOD, y el producto final integra el mismo módulo de cómputo en una placa propia sin rehacer el software.
graph TB
subgraph LNX["Nerves — Linux debajo"]
L1["Aplicación Elixir"] --> L2["BEAM"] --> L3["libc · procesos · VFS · drivers"] --> L4["Kernel Linux"] --> L5["Hardware"]
end
subgraph GRP["GRiSP — RTEMS debajo"]
R1["Aplicación Erlang / Elixir"] --> R2["BEAM"] --> R3["RTEMS: planificador de tiempo real,<br/>pila de red, drivers"] --> R4["Hardware iMX6UL"]
end
LNX -->|"más capas: más funciones,<br/>menos determinismo, arranque lento"| D{"¿Qué necesita el proyecto?"}
GRP -->|"menos capas: arranque de 1 a 2 s,<br/>latencia acotada, menos software que auditar"| D
Quitar Linux tiene cuatro consecuencias prácticas: arranque de uno a dos segundos contra los cuatro a seis de un Nerves típico, lo que en un nodo a batería que despierta cada hora se acumula; latencia acotada, porque RTEMS es un planificador de tiempo real duro con un límite superior demostrable y no un promedio con cola larga; superficie de ataque mucho menor, sin shell ni gestor de paquetes ni millones de líneas de kernel de propósito general; y, del otro lado del trato, no hay ecosistema de Linux: no se pueden usar bibliotecas del sistema, ni ejecutar procesos externos, ni apoyarse en un driver que alguien ya escribió para un chip raro. Todo lo que el dispositivo hace, lo hace la BEAM.
El flujo de trabajo
El toolchain de referencia es rebar3 con el complemento rebar3_grisp. Erlang es el lenguaje de primera clase; Elixir puede incluirse en el release, pero el camino documentado pasa por Erlang.
# En ~/.config/rebar3/rebar.config: {plugins, [rebar3_grisp]}.
rebar3 new grispapp name=sensor_grisp dest=/media/usuario/GRISP
cd sensor_grisp
rebar3 grisp build # compila OTP para la placa; la primera vez tarda mucho
rebar3 grisp deploy # arma el release y lo copia al medio de arranque
%% rebar.config
{erl_opts, [debug_info]}.
{deps, [grisp]}.
{grisp, [
{otp, [{version, "27"}]},
{deploy, [{destination, "/media/usuario/GRISP"}]}
]}.
{shell, [{apps, [sensor_grisp]}]}.
rebar3 grisp build compila una versión de Erlang/OTP para el hardware de la placa con los parches de RTEMS aplicados; el resultado queda en caché para las siguientes veces. rebar3 grisp deploy produce los archivos de arranque y los escribe donde indique la configuración.
Código en la placa
Los LED de la placa son el “hola mundo” del ecosistema:
1> grisp_led:color(1, red).
2> grisp_led:flash(1, green, 500).
3> grisp_led:pattern(2, [{300, red}, {300, green}, {300, blue}]).
4> grisp_led:off(1).
Un gen_server que hace de indicador del estado del sistema:
-module(sensor_grisp_indicador).
-behaviour(gen_server).
-export([start_link/0, estado/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
-define(LED, 1).
start_link() -> gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
%% arrancando | ok | sin_red | error
estado(Nuevo) -> gen_server:cast(?MODULE, {estado, Nuevo}).
init([]) ->
grisp_led:pattern(?LED, [{200, yellow}, {200, off}]),
{ok, #{estado => arrancando}}.
handle_call(_Peticion, _Desde, Estado) -> {reply, {error, no_implementado}, Estado}.
handle_info(_Mensaje, Estado) -> {noreply, Estado}.
handle_cast({estado, Nuevo}, Estado) ->
aplicar(Nuevo),
{noreply, Estado#{estado := Nuevo}}.
aplicar(ok) -> grisp_led:color(?LED, green);
aplicar(sin_red) -> grisp_led:flash(?LED, yellow, 1000);
aplicar(error) -> grisp_led:pattern(?LED, [{150, red}, {150, off}]);
aplicar(arrancando) -> grisp_led:color(?LED, blue).
Desde Elixir esos mismos módulos se llaman con sintaxis de átomos: :grisp_led.color(1, :red). La biblioteca grisp incluye además módulos de acceso a los buses (grisp_gpio, grisp_i2c, grisp_spi, grisp_onewire) y drivers para los módulos PMOD que se conectan a los zócalos de expansión —acelerómetros, giroscopios, sensores de luz ambiental, telémetros ultrasónicos y unidades de navegación inercial—, de modo que conectar un PMOD y leerlo no obliga a escribir el protocolo del chip.
Distribución de Erlang entre placas
La característica que más se aprovecha en GRiSP no tiene que ver con el hardware: es la distribución nativa de Erlang. Varias placas en la misma red forman un clúster y se envían mensajes como si los procesos fueran locales.
1> net_adm:ping('[email protected]').
pong
2> gen_server:cast({sensor_grisp_indicador, '[email protected]'}, {estado, ok}).
ok
Eso convierte una red de placas en un solo sistema distribuido, con supervisión, monitoreo de nodos y detección de particiones incluidos: exactamente el caso de uso que el proyecto declara como objetivo, en redes, automatización e infraestructura distribuida. Para gestión de flota y actualizaciones existe GRiSP.io, el equivalente de NervesHub en ese ecosistema.
Cuándo usar cada cosa
| Criterio | AtomVM | Nerves | GRiSP 2 |
|---|---|---|---|
| Costo del hardware | Muy bajo | Bajo a medio | Alto |
| Consumo típico | Miliamperios | Cientos de mA | Decenas a cientos de mA |
| Sueño profundo | Nativo del chip | Requiere placa externa (Soleil) | Soportado por el hardware |
| BEAM completa | No: subconjunto | Sí | Sí |
| Sistema de archivos | Muy limitado | Completo | Limitado |
| TLS y criptografía | Limitado | Completo | Con motor por hardware |
| Ecosistema de bibliotecas | Reducido | Todo Hex que no dependa de NIF exóticos | Erlang, con drivers propios |
| Tiempo real duro | Aproximado | No | Sí, con RTEMS |
| Actualización OTA gestionada | Propia | NervesHub | GRiSP.io |
| Grado industrial | Depende del módulo | Depende de la placa | Componentes industriales |
Traducido a decisiones concretas: un sensor a batería que reporta cada hora y debe costar poco pide AtomVM sobre ESP32. Un nodo que necesita red, almacenamiento local, panel web y actualización remota gestionada pide Nerves sobre Raspberry Pi, con Soleil si va a batería. Un controlador industrial con requisitos de latencia acotada y arranque rápido pide GRiSP 2. Y un robot articulado pide Nerves para el cerebro y la coordinación, con Beam Bots o un modelo OTP propio, más microcontroladores dedicados para los lazos de control de cada motor.
La combinación híbrida es la más frecuente en proyectos reales y es la que el propio ecosistema promueve: microcontroladores en el borde midiendo y actuando, un nodo Nerves coordinando y guardando historial, y un backend en la nube consolidando flotas.
Errores comunes
| Síntoma | Causa | Solución |
|---|---|---|
mix firmware compila para la máquina de desarrollo | MIX_TARGET no está exportada en esa terminal | export MIX_TARGET=rpi4 y volver a ejecutar mix deps.get antes de compilar |
| Errores de enlazado tras cambiar de placa | Quedaron artefactos compilados para el objetivo anterior | Limpiar _build y deps del objetivo afectado y resolver dependencias de nuevo |
La aplicación falla con :eacces al escribir un archivo | Se intentó escribir en el rootfs, que es de solo lectura | Escribir siempre bajo /data, y en host usar un directorio temporal |
| El dispositivo revierte al firmware anterior tras cada actualización | Nadie llamó a Nerves.Runtime.validate_firmware/0 dentro de la ventana de validación | Agregar un proceso validador que compruebe salud y valide, como el del ejemplo |
| El nodo queda inalcanzable tras actualizar | El firmware nuevo traía la configuración de red rota y aun así se validó | Incluir la conectividad entre los criterios de salud y desplegar por cohortes en NervesHub |
Circuits.GPIO.open devuelve {:error, :enoent} | La especificación de línea no existe en esa placa | Ejecutar Circuits.GPIO.enumerate/0 y usar el nombre o la tupla {"gpiochip0", n} que aparece ahí |
| Código de GPIO de Circuits 1.x que falla con 2.x | Cambió la identificación de líneas y el formato del mensaje de interrupción | Migrar de números sueltos a especificaciones y ajustar el handle_info al formato {:circuits_gpio, spec, ts, valor} |
Circuits.I2C.detect_devices/0 no muestra el sensor | El bus no está habilitado en el device tree, o falta alimentación o pull-up | Verificar la habilitación del bus, revisar los 3,3 V y confirmar los pull-up de SDA y SCL |
| Un sensor I2C responde a veces | Cables largos, velocidad de bus alta o pull-up de valor inadecuado | Acortar el cableado, bajar la frecuencia del bus y ajustar los pull-up a 2,2 – 4,7 kΩ |
SPI devuelve siempre ceros o siempre 0xFF | Modo de reloj equivocado, línea de selección mal asignada o MISO y MOSI intercambiados | Confirmar el modo en el datasheet y verificar qué spidevX.Y corresponde al pin de selección usado |
| El puerto serie entrega fragmentos cortados | No se configuró framing y se lee el flujo crudo | Configurar framing: {Circuits.UART.Framing.Line, separator: "\r\n"} acorde a lo que envía el otro extremo |
| El nodo a batería nunca vuelve a despertar | Se apagó sin programar la fuente de despertado en la placa de energía | Programar el despertar antes de llamar a poweroff/0 y validar esa secuencia en el banco antes de instalar en campo |
| El nodo se apaga a mitad de una subida de datos | No hay coordinación entre el proceso que publica y el que decide dormir | Usar el patrón de retención, con un plazo máximo de seguridad |
| La batería se agota en días pese al modo de sueño | Algún periférico sigue alimentado o el ciclo de trabajo real supera al previsto | Medir el consumo con la placa apagada y registrar el tiempo despierto real de cada ciclo |
| El certificado del dispositivo desaparece al cambiar la tarjeta SD | La clave privada se guardó en el sistema de archivos y no en el elemento seguro | Usar el ATECC608A con nerves_key, de modo que la identidad viva en la placa |
| Una articulación llega a un ángulo imposible y traba la mecánica | El comando no se recortó contra los límites declarados | Recortar dentro del proceso de la articulación, no en el llamador, para que ningún camino de código se lo salte |
| Una biblioteca de Hex no funciona en GRiSP | Depende de bibliotecas del sistema o de ejecutar procesos externos, que RTEMS no ofrece | Buscar una alternativa en Erlang o Elixir puro, o implementar lo necesario sobre los módulos de grisp |
Ejercicios propuestos
-
Primer firmware. Crea un proyecto con
mix nerves.new, hazlo parpadear un LED concircuits_gpioy grábalo en una tarjeta. Luego cambia el intervalo y actualiza conmix uploadsin sacar la tarjeta. Cronometra ambos ciclos y anota la diferencia. -
Anatomía del
.fw. Lista las tareas del archivo de firmware con las herramientas defwupy describe con tus palabras qué hace cada una. Compara el tamaño del.fwcon el del release de Elixir y explica de dónde sale la diferencia. -
Validación con criterio. Implementa un validador que solo declare sano el firmware si hay red y el sensor principal entregó tres lecturas coherentes. Prueba a propósito un firmware con la configuración de Wi-Fi rota y verifica que el dispositivo revierte solo.
-
Datos que sobreviven. Guarda una lectura por minuto en
/datay exponlas por HTTP. Reinicia el dispositivo veinte veces cortando la energía sin apagado ordenado y comprueba si se pierde algún registro o se corrompe el archivo. -
Driver propio. Elige un sensor I2C que tengas, lee su datasheet e implementa el driver completo: identificación del chip, configuración, lectura y compensación. Escribe pruebas con CircuitsSim que verifiquen la matemática sin el sensor conectado.
-
Del bloqueo a la concurrencia. Escribe un módulo que lea tres sensores I2C lentos en secuencia dentro de un solo
GenServery otro que use un proceso por sensor. Mide en ambos casos cuánto tarda en responder una petición no relacionada mientras las lecturas ocurren. -
Presupuesto de energía. Calcula, para un Raspberry Pi Zero 2 W con batería de 3000 mAh, cuántos días dura con ciclos de trabajo de 100 %, 5 % y 0,5 %. Luego mídelo durante 48 horas y compara la predicción con el resultado.
-
Ciclo de sueño robusto. Implementa el patrón de retención y prueba tres escenarios: un proceso que retiene y libera normalmente, uno que retiene y muere sin liberar, y uno que retiene indefinidamente. Verifica que en los tres casos el nodo termina durmiendo.
-
DSL propio. Extiende
Robotica.Topologiapara admitir articulaciones prismáticas, con límites en metros en vez de radianes, y para validar en tiempo de compilación que todopadrey todohijoreferencien eslabones declarados. Provoca el error a propósito y comprueba que el módulo no compila. -
Cinemática inversa. Implementa la cinemática inversa del brazo plano de dos eslabones: dada una posición
{x, y}, calcula los ángulos que la alcanzan. Maneja explícitamente el punto inalcanzable y las dos soluciones posibles, codo arriba y codo abajo. -
Fallas y supervisión. Mata a propósito el proceso de una articulación mientras un comando está en ejecución. Documenta qué ocurre, qué devuelve el comando y qué política de supervisión —
:one_for_one,:one_for_allo:rest_for_one— es la correcta para un brazo articulado, con su justificación.
Lo que viene
Con este capítulo queda cerrada la parte de plataformas. Ya sabes construir una imagen de firmware reproducible con Nerves y actualizarla sin ir al lugar, hablar con cualquier bus de hardware desde procesos supervisados con Elixir Circuits, sostener un nodo a batería coordinando su sueño con Soleil, modelar un robot articulado sobre OTP en vez de arrastrar un middleware externo, y evaluar cuándo conviene sacar a Linux de la ecuación y correr sobre RTEMS con GRiSP.
Queda un tema que este capítulo tocó de refilón y que decide si un proyecto en campo funciona o no: la energía. Los números del ciclo de trabajo, el dimensionamiento del panel, la química de la batería, el controlador de carga, la irradiancia real del lugar y los días sin sol que hay que soportar son un problema de ingeniería completo, con su propia física y sus propios errores caros. En el capítulo 17 lo desarmamos entero: desde cómo se comporta una celda fotovoltaica hasta cómo se calcula, con datos concretos, cuántos vatios-pico y cuántos amperios-hora necesita un nodo para sobrevivir un invierno nublado. El índice completo del curso está en la portada.