Hardware avanzado con la BEAM: Nerves, Elixir Circuits, Soleil, Beam Bots y GRiSP

Por: Artiko
elixirroboticaiotelectronicanervescircuitsgrispbeamotaotp

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.

PlataformaCapa bajo la BEAMRAM típicaArranqueActualización remotaDeterminismo
AtomVMNinguna o FreeRTOS320 KBmenos de 1 sManual u OTA propiaAlto
NervesLinux Buildroot512 MB – 8 GB2 – 6 sNativa: A/B + NervesHubMedio
GRiSP 2RTEMS128 MB1 – 2 sGRiSP.ioAlto
Linux de distribuciónDebian, Ubuntu1 GB o más20 – 60 sGestor de paquetesBajo

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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_TARGETHardwareNotas
hostTu máquina de desarrolloPruebas y lógica sin hardware
rpi0 / rpi0_2Raspberry Pi Zero y Zero 2 WConsumo bajo
rpi3 / rpi3aRaspberry Pi 3 y 3A+Wi-Fi y Bluetooth integrados
rpi4Raspberry Pi 4El objetivo más ejercitado del ecosistema
rpi5Raspberry Pi 5GPIO detrás del southbridge RP1
bbbBeagleBone Black y variantesIncluye los PRU de tiempo real
grisp2GRiSP 2 con LinuxAlternativa al camino RTEMS
osd32mp1Octavo OSD32MP1 (STM32MP1)Industrial, Cortex-A7 más Cortex-M4
x86_64PC industrial, mini PC, máquina virtualGateways 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ónQué hace
Nerves.Runtime.reboot/0Reinicia de forma ordenada, cerrando la BEAM primero
Nerves.Runtime.poweroff/0Apaga el sistema
Nerves.Runtime.validate_firmware/0Marca la partición actual como buena
Nerves.Runtime.firmware_valid?/0Indica si la partición actual ya fue validada
Nerves.Runtime.serial_number/0Número de serie del hardware
Nerves.Runtime.KV.get/1Lee una variable del entorno de firmware
Nerves.Runtime.KV.get_active/1Lee 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 SoleilProblema que resuelve
Batería recargableEl nodo debe seguir funcionando sin red eléctrica
Carga por USB-C y por panel solarReponer energía en el lugar, con o sin sol
Modo de bajo consumo con corte de energía al PiUn Pi encendido de forma permanente agota cualquier batería razonable
Tres fuentes de despertadoEl 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.2Encadenar 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.

EstrategiaCiclo de trabajoConsumo medioAutonomía con 11 Wh
Siempre encendido100 %~0,6 W~18 horas
Despierta 2 min cada hora3,3 %~40 mW~11 días
Despierta 1 min cada 6 horas0,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 ROS2Equivalente nativo en la BEAM
Tópicos con publicación/suscripciónPhoenix.PubSub, Registry con claves duplicadas, mensajes de Erlang
Servicios de petición/respuesta síncronosGenServer.call/3, con su plazo de espera incorporado
Acciones de larga duración con avance parcialTask.Supervisor sobre Task, con mensajes de progreso al llamador
Nodos con ciclo de vida y transicionesGenServer con máquina de estados explícita, o :gen_statem
Servidor de parámetros en tiempo de ejecuciónETS o :persistent_term
Descubrimiento de nodos entre máquinasDistribució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 2Especificación
ProcesadorNXP i.MX6UL, ARM Cortex-A7 a 696 MHz
Caché128 KB de nivel 2
Memoria128 MB DDR3
Almacenamiento4 GB eMMC, más ranura microSD
CriptografíaMotor por hardware AES, TDES y SHA; arranque seguro
ConectividadWi-Fi, Ethernet, USB
ExpansiónConectores PMOD (SPI, I2C, UART, GPIO) y zócalo mikroBUS
FormatoSistema 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

CriterioAtomVMNervesGRiSP 2
Costo del hardwareMuy bajoBajo a medioAlto
Consumo típicoMiliamperiosCientos de mADecenas a cientos de mA
Sueño profundoNativo del chipRequiere placa externa (Soleil)Soportado por el hardware
BEAM completaNo: subconjunto
Sistema de archivosMuy limitadoCompletoLimitado
TLS y criptografíaLimitadoCompletoCon motor por hardware
Ecosistema de bibliotecasReducidoTodo Hex que no dependa de NIF exóticosErlang, con drivers propios
Tiempo real duroAproximadoNoSí, con RTEMS
Actualización OTA gestionadaPropiaNervesHubGRiSP.io
Grado industrialDepende del móduloDepende de la placaComponentes 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íntomaCausaSolución
mix firmware compila para la máquina de desarrolloMIX_TARGET no está exportada en esa terminalexport MIX_TARGET=rpi4 y volver a ejecutar mix deps.get antes de compilar
Errores de enlazado tras cambiar de placaQuedaron artefactos compilados para el objetivo anteriorLimpiar _build y deps del objetivo afectado y resolver dependencias de nuevo
La aplicación falla con :eacces al escribir un archivoSe intentó escribir en el rootfs, que es de solo lecturaEscribir siempre bajo /data, y en host usar un directorio temporal
El dispositivo revierte al firmware anterior tras cada actualizaciónNadie llamó a Nerves.Runtime.validate_firmware/0 dentro de la ventana de validaciónAgregar un proceso validador que compruebe salud y valide, como el del ejemplo
El nodo queda inalcanzable tras actualizarEl 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 placaEjecutar 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.xCambió la identificación de líneas y el formato del mensaje de interrupciónMigrar 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 sensorEl bus no está habilitado en el device tree, o falta alimentación o pull-upVerificar la habilitación del bus, revisar los 3,3 V y confirmar los pull-up de SDA y SCL
Un sensor I2C responde a vecesCables largos, velocidad de bus alta o pull-up de valor inadecuadoAcortar el cableado, bajar la frecuencia del bus y ajustar los pull-up a 2,2 – 4,7 kΩ
SPI devuelve siempre ceros o siempre 0xFFModo de reloj equivocado, línea de selección mal asignada o MISO y MOSI intercambiadosConfirmar el modo en el datasheet y verificar qué spidevX.Y corresponde al pin de selección usado
El puerto serie entrega fragmentos cortadosNo se configuró framing y se lee el flujo crudoConfigurar 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 despertarSe apagó sin programar la fuente de despertado en la placa de energíaProgramar 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 datosNo hay coordinación entre el proceso que publica y el que decide dormirUsar 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ñoAlgún periférico sigue alimentado o el ciclo de trabajo real supera al previstoMedir el consumo con la placa apagada y registrar el tiempo despierto real de cada ciclo
El certificado del dispositivo desaparece al cambiar la tarjeta SDLa clave privada se guardó en el sistema de archivos y no en el elemento seguroUsar 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ánicaEl comando no se recortó contra los límites declaradosRecortar 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 GRiSPDepende de bibliotecas del sistema o de ejecutar procesos externos, que RTEMS no ofreceBuscar una alternativa en Erlang o Elixir puro, o implementar lo necesario sobre los módulos de grisp

Ejercicios propuestos

  1. Primer firmware. Crea un proyecto con mix nerves.new, hazlo parpadear un LED con circuits_gpio y grábalo en una tarjeta. Luego cambia el intervalo y actualiza con mix upload sin sacar la tarjeta. Cronometra ambos ciclos y anota la diferencia.

  2. Anatomía del .fw. Lista las tareas del archivo de firmware con las herramientas de fwup y describe con tus palabras qué hace cada una. Compara el tamaño del .fw con el del release de Elixir y explica de dónde sale la diferencia.

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

  4. Datos que sobreviven. Guarda una lectura por minuto en /data y 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.

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

  6. Del bloqueo a la concurrencia. Escribe un módulo que lea tres sensores I2C lentos en secuencia dentro de un solo GenServer y 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.

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

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

  9. DSL propio. Extiende Robotica.Topologia para admitir articulaciones prismáticas, con límites en metros en vez de radianes, y para validar en tiempo de compilación que todo padre y todo hijo referencien eslabones declarados. Provoca el error a propósito y comprueba que el módulo no compila.

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

  11. 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_all o :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.