CAD, compra de componentes, RTOS y ROS: del dibujo a la pieza y del hilo al robot

Por: Artiko
elixirroboticaiotelectronicacadkicadopenscadfreertoszephyrros2

CAD, compra de componentes, RTOS y ROS: del dibujo a la pieza y del hilo al robot

En el capitulo 11 cerramos el circuito del software: compilamos firmware con PlatformIO, publicamos telemetria hacia Home Assistant, la almacenamos y la graficamos en Grafana. Al final de ese capitulo tenias un sistema que funciona, pero que probablemente vive sobre una protoboard apoyada en el escritorio, con cables sueltos, alimentado por un cargador de celular y sostenido por la esperanza.

Este capitulo trata de todo lo que hay entre ese prototipo y algo que se pueda instalar, vender, prestar o dejar funcionando seis meses sin tocarlo. Son cuatro temas que parecen inconexos pero que en la practica se resuelven en la misma semana de trabajo:

  1. CAD, en sus dos sabores: el diseño electronico (esquematico y placa de circuito impreso) y el diseño mecanico (la carcasa, el soporte, el engranaje).
  2. Donde comprar, que es un problema de ingenieria disfrazado de compra: el costo real de un componente incluye envio, impuestos, tiempo de espera y riesgo de falsificacion.
  3. RTOS, los sistemas operativos de tiempo real que corren dentro del microcontrolador y garantizan que una tarea se ejecute a tiempo, no solo “en algun momento”.
  4. ROS, el Robot Operating System, que no es un sistema operativo sino un middleware distribuido, y que es el estandar de facto cuando el robot pasa de ser un LED que parpadea a tener percepcion, navegacion y planificacion.

El hilo que une los cuatro es el mismo de todo el curso: entender el mecanismo real, no la caja generica. Vamos por partes.

Que es CAD y por que en robotica son dos mundos separados

CAD significa Computer-Aided Design, diseño asistido por computador. Es un termino tan amplio que por si solo no dice nada util. En robotica y electronica se divide en dos familias que casi no se hablan entre si, con archivos, vocabulario y flujos de trabajo distintos:

  • EDA (Electronic Design Automation): dibujar el esquematico del circuito y convertirlo en una placa de circuito impreso. La salida final son archivos Gerber y una lista de materiales (BOM) que se entregan a una fabrica.
  • CAD mecanico: modelar solidos en tres dimensiones. La salida final es un STEP (para mecanizar o para que otro ingeniero lo edite) o un STL/3MF (para imprimir en 3D).

La confusion mas comun de quien empieza es creer que un programa hace ambas cosas. Existen suites integradas, pero en la practica se usa una herramienta por dominio y se exportan modelos entre ellas: KiCad exporta la placa como STEP con todos sus componentes en 3D, y ese STEP se importa en FreeCAD para diseñar la carcasa alrededor de la placa real y no de una imaginada.

flowchart TD
    subgraph EDA["Mundo EDA (electronico)"]
        E1[Esquematico<br/>simbolos y conexiones] --> E2[Asignacion de huellas<br/>footprints]
        E2 --> E3[Ruteo de la placa<br/>PCB layout]
        E3 --> E4{DRC<br/>reglas de diseño}
        E4 -- errores --> E3
        E4 -- limpio --> E5[Gerber + drill + BOM<br/>+ pick and place]
    end

    subgraph MEC["Mundo CAD mecanico"]
        M1[Boceto 2D<br/>con restricciones] --> M2[Solido 3D<br/>extrusion, revolucion]
        M2 --> M3[Ensamblaje<br/>tolerancias y choques]
        M3 --> M4[STEP para mecanizar<br/>STL/3MF para imprimir]
    end

    E3 -- exporta STEP<br/>de la placa --> M3
    E5 --> F1[Fabrica de PCB]
    M4 --> F2[Impresora 3D<br/>o taller CNC]
    F1 --> P[Prototipo ensamblado]
    F2 --> P

Fijate en la flecha que cruza de EDA a mecanico. Es la union critica y la fuente del error mas caro del capitulo: diseñar la carcasa antes de congelar la placa, y descubrir despues que el conector USB quedo tres milimetros mas arriba que la ranura.

Herramientas EDA: del esquematico al gerber

KiCad

Es la suite EDA libre de referencia, mantenida por la KiCad Project bajo el paraguas del CERN y de una fundacion propia. Incluye editor de esquematicos, editor de PCB, visor 3D, calculadora de pistas y editor de bibliotecas de simbolos y huellas. No tiene limite de capas, ni de area de placa, ni de uso comercial.

Su curva de aprendizaje es la mas empinada de las opciones libres, pero es la unica que no te deja en un callejon sin salida cuando el proyecto crece. El flujo tipico es: dibujar el esquematico con simbolos de la biblioteca; anotar, que asigna las referencias R1, C3, U2; correr el ERC (Electrical Rules Check), que detecta salidas conectadas entre si, entradas al aire y redes de alimentacion sin fuente; asignar a cada simbolo su huella fisica —una resistencia en el esquematico es solo un simbolo, en la placa puede ser 0805, 0603 o through-hole, y esa decision se toma aqui—; rutear; correr el DRC (Design Rules Check), que verifica separaciones minimas, anchos de pista y taladros fuera de zona; y finalmente exportar gerber, taladros, BOM y posiciones de montaje.

EasyEDA

Herramienta web, gratuita, ligada a LCSC (distribuidor de componentes) y a JLCPCB (fabrica de PCB). Su gran ventaja no es tecnica sino logistica: la biblioteca de componentes esta enlazada al inventario real de LCSC, con precio y stock visibles mientras dibujas, y el envio a fabricacion es un boton. La contrapartida es el acoplamiento: te empuja de forma natural hacia un unico proveedor.

Es la mejor opcion para una primera placa; si el proyecto va a durar años, conviene migrar a KiCad.

Autodesk Eagle y Altium

Eagle fue durante decadas la herramienta estandar del maker. Autodesk la absorbio y la fusiono dentro de Fusion como su entorno de electronica; el producto independiente ya no recibe desarrollo nuevo, aunque KiCad importa sus formatos .sch y .brd. Altium Designer es la referencia profesional de la industria (gestion de variantes, integridad de señal, trabajo multiusuario); su licencia esta fuera de rango para un proyecto personal, pero conviene conocerla porque es el vocabulario del ambiente laboral.

Proteus y Wokwi

Proteus (de Labcenter) combina captura de esquematico con simulacion mixta: simula el circuito analogico y, al mismo tiempo, ejecuta el firmware compilado dentro de un modelo de microcontrolador. Ves el LED encenderse porque tu codigo realmente puso el pin en alto en el modelo. Para enseñanza es muy poderoso; es propietario y de pago.

Wokwi es un simulador online, gratuito para uso basico, especializado en ESP32, Arduino, Raspberry Pi Pico y STM32. No simula fisica analogica al nivel de Proteus, pero si perifericos digitales, sensores comunes, pantallas OLED y la pila de WiFi del ESP32, incluyendo conexion a servicios externos. Es la forma mas rapida de probar firmware sin tener el hardware sobre la mesa, especialmente util mientras esperas que llegue el paquete.

Tabla comparativa de herramientas EDA

HerramientaLicenciaTipoSimulacionFuerte enLimite practico
KiCadLibre (GPL)EscritorioSPICE integradoControl total, sin limites, scriptable en PythonCurva de aprendizaje
EasyEDAGratuito, propietarioWeb + escritorioBasicaBiblioteca ligada a stock real, envio a fabrica en un clicAcoplado a LCSC/JLCPCB
EaglePropietario (dentro de Fusion)EscritorioLimitadaEnorme base de proyectos historicosProducto en fin de vida
AltiumPropietario, alto costoEscritorioAvanzadaEstandar industrial, integridad de señalPrecio
ProteusPropietario, de pagoEscritorioMixta con firmware realEnseñanza, depurar codigo sin hardwarePrecio, catalogo de MCU
WokwiFreemiumWebDigital + WiFiPrototipo instantaneo de ESP32/ArduinoNo reemplaza medicion real

Criterio simple: Wokwi para probar la idea, EasyEDA para la primera placa, KiCad para el proyecto que va a durar.

Que archivos entrega realmente uno a la fabrica

Este es el punto donde mas gente se traba: la fabrica no quiere tu proyecto, quiere un paquete de archivos planos.

ArchivoQue describeNota
Gerber (.gbr, uno por capa)Cobre, mascara antisoldante, serigrafia, contornoEl estandar actual es Gerber X2, que ademas lleva metadatos
Excellon (.drl)Coordenadas y diametros de taladrosSeparar taladros metalizados de no metalizados
BOM (.csv)Referencia, cantidad, valor, huella, codigo del distribuidorSin codigo de parte, el ensamblador no puede comprar
Centroid / pick and place (.csv)Coordenadas X, Y, rotacion y cara de cada componenteSolo si pides montaje automatico
README de fabricacionEspesor, color, acabado, tolerancia de impedanciaEvita la mitad de los correos de ida y vuelta

Formatos mas nuevos como ODB++ o IPC-2581 empaquetan todo en un solo archivo, con la informacion de capas y de netlist incluida; cada vez mas fabricas los aceptan y eliminan la clase entera de errores de “faltaba una capa”.

Fabricacion de placas: donde mandar el gerber

FabricanteOrigenFuerteConsideracion
JLCPCBChinaPrecio muy bajo en prototipo, ensamblado SMT economicoEnvio internacional, IVA de importacion
PCBWayChinaAmplio catalogo de procesos (flex, aluminio, mecanizado CNC, impresion 3D)Igual que arriba
OSH ParkEEUUCalidad muy consistente, placas moradas de 2 y 4 capasPrecio por pulgada cuadrada, no por lote
AislerEuropaBuen soporte, plazos claros dentro de la UEPrecio mayor que Asia
PCBChileChileProduccion local, sin aduana ni espera internacionalVolumenes y procesos mas acotados
MCI ElectronicsChileServicio integral: componentes, PCB y ensambladoEnfocado a proyecto, no a unidad suelta
SLTechChilePrototipado y produccion localCotizacion caso a caso
CIGAChileManufactura electronica y ensamblajeOrientado a produccion

La regla practica: si el prototipo es de una o dos unidades y no hay apuro, Asia gana por precio incluso sumando envio; si necesitas iterar cada semana o el proyecto tiene plazos comprometidos, la fabricacion local se paga sola, porque una iteracion perdida cuesta mas que la diferencia de precio.

CAD mecanico: la carcasa, el soporte y la pieza

Aqui cambia el vocabulario: en vez de redes y huellas hay bocetos, restricciones, operaciones y ensamblajes. Y hay dos filosofias muy distintas de modelado.

Modelado por solidos (B-Rep) frente a modelado por malla

  • B-Rep (Boundary Representation): la pieza se describe por sus superficies matematicas exactas. Un cilindro es un cilindro, no una aproximacion. Es lo que usan FreeCAD, Fusion, Inventor, SolidWorks, Onshape. Permite decir “redondea esta arista 2 mm” y que el resultado sea exacto. Formato nativo de intercambio: STEP.
  • Malla (mesh): la pieza se describe por miles de triangulos. Es lo que usan Blender y lo que produce un escaner 3D. Perfecto para formas organicas, pesimo para tolerancias. Formato: STL, OBJ, 3MF.

El error clasico es modelar una caja tecnica en Blender: se ve bien y no calza en la vida real, porque un agujero “de 3 mm” hecho en malla en realidad es un poligono de 32 lados inscrito en 3 mm, y termina midiendo 2.97.

Modelado parametrico e historial de operaciones

Un modelador parametrico guarda la secuencia de operaciones, no solo el resultado: boceto restringido, extrusion, vaciado, patron de agujeros, redondeo. Si dibujaste una base de 60 x 40 mm y despues cambias 60 por 70 en el primer boceto, todas las operaciones posteriores se recalculan solas. Eso es lo que hace utilizable una carcasa cuando cambia la bateria o el conector, y es exactamente lo que un modelo de malla no puede hacer: en una malla no queda registro de que ese agujero “era un agujero de 3 mm”, solo quedan triangulos.

Comparacion de herramientas CAD 3D

HerramientaLicenciaParadigmaFuerte enConsideracion
FreeCADLibre (LGPL)Parametrico B-RepBancos de trabajo especializados, sin nube, formato abiertoInterfaz densa; historicamente sensible a romper el arbol al editar
OpenSCADLibre (GPL)Parametrico por codigoPiezas tecnicas versionables en git, generacion programaticaSin interfaz de dibujo; no adecuado para superficies organicas
Autodesk FusionGratis para uso personal, de pago comercialParametrico + CAM + EDATodo en uno, simulacion y mecanizadoDepende de la nube; la licencia gratuita cambia condiciones
Autodesk InventorPropietarioParametricoEnsamblajes grandes, documentacion de planosCosto, solo Windows
SolidWorksPropietarioParametricoEstandar de la industria mecanicaCosto
OnshapeFreemium webParametrico en la nubeColaboracion real, control de versiones tipo gitEn el plan gratuito los documentos son publicos
TinkercadGratuito webSolidos primitivosAprender en una tarde, ideal para docenciaTecho bajo para piezas tecnicas
BlenderLibre (GPL)MallaFormas organicas, render, animacionNo es CAD de precision

Una carcasa parametrica completa en OpenSCAD

OpenSCAD merece un ejemplo completo porque encaja perfecto con la mentalidad de este curso: la pieza es codigo, entra a git, se revisa en un pull request y se regenera desde la linea de comandos. Este archivo genera una caja de dos partes para una placa ESP32 DevKit, con recorte para el USB, torres de tornillo y ranuras de ventilacion.

// carcasa_esp32.scad
// Caja parametrica de dos piezas para una placa tipo ESP32 DevKit.
// Uso: openscad -D pieza=\"base\" -o base.stl carcasa_esp32.scad

// ---------- Parametros editables ----------
placa_x = 55;   placa_y = 28;   placa_z = 1.6;  // dimensiones del PCB
alto_libre = 16;                // espacio sobre la placa (headers, antena)
holgura = 0.4;                  // juego entre placa y pared interior
pared = 2.0;    piso = 2.0;     // espesores
torre_d = 6.0;  torre_hueco = 2.5;  // torre y agujero autorroscante M3
usb_ancho = 9.0; usb_alto = 4.5;    // recorte del conector USB
sep_tapa = 0.25;                // holgura entre labio de la tapa y la base
pieza = "base";                 // "base" | "tapa" | "ambas"

$fn = 64;                       // resolucion de los cilindros

// ---------- Dimensiones derivadas ----------
int_x = placa_x + 2 * holgura;
int_y = placa_y + 2 * holgura;
int_z = placa_z + alto_libre;
ext_x = int_x + 2 * pared;
ext_y = int_y + 2 * pared;
ext_z = int_z + piso;

// ---------- Modulos ----------
module torre() {
    difference() {
        cylinder(h = piso + 3, d = torre_d);
        translate([0, 0, piso]) cylinder(h = 4, d = torre_hueco);
    }
}

module torres() {
    margen = torre_d / 2 + 1;
    for (px = [margen, int_x - margen])
        for (py = [margen, int_y - margen])
            translate([pared + px, pared + py, 0]) torre();
}

module ranuras_ventilacion() {
    largo = int_y * 0.6;
    for (i = [0 : 4])
        translate([ext_x * 0.25 + i * 5, (ext_y - largo) / 2, -1])
            cube([2, largo, piso + 2]);
}

module base() {
    difference() {
        // cuerpo exterior
        cube([ext_x, ext_y, ext_z]);
        // cavidad interior
        translate([pared, pared, piso])
            cube([int_x, int_y, int_z + 1]);
        // recorte para el conector USB en la cara X = 0
        translate([-1, (ext_y - usb_ancho) / 2, piso + placa_z])
            cube([pared + 2, usb_ancho, usb_alto]);
        // ventilacion en el piso
        ranuras_ventilacion();
    }
    torres();
}

module tapa() {
    labio_alto = 3;
    union() {
        // plancha superior
        cube([ext_x, ext_y, pared]);
        // labio que entra en la base
        difference() {
            translate([pared + sep_tapa, pared + sep_tapa, -labio_alto])
                cube([int_x - 2 * sep_tapa, int_y - 2 * sep_tapa, labio_alto]);
            translate([pared * 2 + sep_tapa, pared * 2 + sep_tapa, -labio_alto - 1])
                cube([int_x - 2 * pared - 2 * sep_tapa,
                      int_y - 2 * pared - 2 * sep_tapa,
                      labio_alto + 2]);
        }
    }
}

// ---------- Salida ----------
if (pieza == "base")  base();
if (pieza == "tapa")  tapa();
if (pieza == "ambas") {
    base();
    translate([0, ext_y + 10, 0]) tapa();
}

Se genera desde la terminal, lo que permite meterlo en un Makefile o en un pipeline de integracion continua:

sudo apt install openscad                                        # Debian/Ubuntu

openscad -D 'pieza="base"' -o base.stl carcasa_esp32.scad        # generar cada pieza
openscad -D 'pieza="tapa"' -o tapa.stl carcasa_esp32.scad

# variante para una placa mas ancha, sin tocar el archivo fuente
openscad -D 'pieza="base"' -D 'placa_y=32' -o base_ancha.stl carcasa_esp32.scad

Ese ultimo comando es el argumento entero a favor de OpenSCAD: una variante de la carcasa es una linea de shell, no media hora de clics.

Tolerancias: el numero que decide si la pieza sirve

Una impresora 3D FDM no imprime lo que dibujaste, sino lo que dibujaste mas o menos el ancho de una linea de material: un agujero de 3.0 mm sale de 2.8. Estas holguras tipicas ahorran muchas reimpresiones.

SituacionValor tipicoExplicacion
Tornillo M3 pasante3.3 a 3.5 mmEl tornillo debe girar libre
Tornillo M3 autorroscante en plastico2.4 a 2.6 mmEl hilo se forma al atornillar
Inserto termico M34.0 a 4.2 mmSe funde con cautin a temperatura baja
Encaje deslizante (tapa y caja)0.2 a 0.3 mm por ladoMenos que eso, la tapa no entra
Encaje a presion0.0 a 0.1 mmRequiere calibrar la impresora primero
Eje de rodamiento 6088.2 mmLos rodamientos no perdonan
Primera capa bajo un puentemas de 0.4 mm de holguraLa primera capa se ensancha

Antes de imprimir la carcasa completa, imprime una pieza de calibracion: una placa de 40 x 40 mm con agujeros de 2.8, 3.0, 3.2, 3.4 y 3.6 mm, y pruebala con el tornillo real. Toma diez minutos y calibra todos los proyectos siguientes.

Formatos de archivo mecanico

FormatoTipoGuardaCuando usarlo
STEP (.step, .stp)B-RepGeometria exacta, ensamblajesIntercambio entre CAD, mecanizado, enviar a un taller
IGES (.igs)B-Rep antiguoSuperficiesSolo por compatibilidad con software viejo
STL (.stl)MallaTriangulos, sin unidades ni colorImpresion 3D basica; formato de facto
3MF (.3mf)Malla + metadatosUnidades, color, material, multiples objetosReemplazo moderno de STL, soportado por los laminadores actuales
OBJ (.obj)MallaTriangulos + texturasRender y visualizacion
FCStd / F3D / SLDPRTNativoHistorial de operacionesTrabajo en curso, nunca para intercambio
G-code (.gcode)InstruccionesMovimientos ya laminadosSalida del laminador hacia la maquina

Una advertencia: el STL no guarda unidades, y la mitad de las piezas que salen diez veces mas grandes o mas chicas vienen de un STL exportado en pulgadas e interpretado en milimetros. El 3MF resuelve exactamente eso, asi que no hay razon para preferir STL si tu laminador acepta 3MF.

Materiales de impresion 3D para robotica

MaterialTemp. boquillaResistencia al calorFuerteDebil
PLA190-220 °CBaja (deforma cerca de 55-60 °C)Facil, preciso, baratoSe deforma dentro de un auto al sol o junto a un driver caliente
PETG230-250 °CMedia (~75 °C)Tenaz, poco quebradizo, resiste humedadTiende a hilar, se raya facil
ABS230-260 °CAlta (~95 °C)Mecanizable, se puede alisar con acetonaSe contrae y despega; requiere camara cerrada y ventilacion
ASA240-260 °CAltaResistente a rayos UV, para exteriorIgual que ABS en dificultad
TPU220-240 °CMediaFlexible: sellos, ruedas, amortiguadoresImpresion lenta, no en extrusor tipo bowden largo
Nylon / PA-CF250-290 °CAltaEngranajes, piezas con cargaAbsorbe humedad; hay que secarlo

Para una carcasa de electronica en interior, PETG es la eleccion por defecto: soporta el calor de un regulador sin deformarse y no se quiebra al apretar un tornillo. Para exterior con sol directo, ASA.

Donde comprar componentes sin sorpresas

Comprar mal es la forma mas silenciosa de arruinar un proyecto: llega tarde, llega falsificado, o llega con un costo tres veces mayor al que esperabas. Conviene razonar en costo total de aterrizaje, que suma al precio de lista el envio internacional, los impuestos de importacion, el costo de oportunidad del tiempo de espera, el riesgo de recibir un lote fuera de especificacion y el costo de reemplazo cuando no hay garantia. Con ese numero en la mano, la decision se vuelve mecanica: proyecto unico y sin apuro, importar; iteracion semanal o produccion, comprar local; componente critico como el microcontrolador o la memoria, distribuidor autorizado siempre.

Tiendas en Chile

El ecosistema chileno de electronica es mas amplio de lo que la mayoria supone. Estos proveedores aparecen habitualmente en proyectos locales.

TiendaPerfil tipico
AltronicsCatalogo amplio, componentes discretos, instrumentos, sucursales
MCI ElectronicsModulos, placas de desarrollo, servicio de PCB y ensamblado
Rambal, Mechastronic Store, HubotRobotica, sensores, actuadores, kits
Makers Chile, CiMech3DEnfoque maker, impresion 3D e insumos
Max Electronica, Electronica Villanelo, RadiovisionComponentes discretos, herramientas, instrumentos
Telectra, Global Chile, Infinito, ArtillecDistribucion de componentes y modulos
Electronica HB, Electro Art, VictronicsComponentes generales y kits
Electronica Ibarra, Electronica HobbyComponentes para aficionado
Afel, Prodeind, Triacs, AutomaSuministro electrico, potencia y automatizacion industrial

Antes de comprar conviene verificar en el sitio de cada tienda su catalogo actual, porque el rubro rota rapido. La ventaja de comprar local no es el precio: es que la resistencia que te falta llega mañana y no en tres semanas.

Distribuidores internacionales

ProveedorPerfilCuando conviene
MouserDistribuidor autorizado, catalogo enorme, hojas de datos completasComponentes criticos, produccion, trazabilidad
DigiKeyIgual que Mouser, con buscador parametrico excelenteCuando necesitas filtrar por 12 parametros a la vez
LCSCCatalogo masivo a bajo costo, base de EasyEDA/JLCPCBPasivos SMD por cientos, integrado con fabricacion
Arrow / FarnellDistribucion autorizada globalVolumen y contratos
AliExpressMarketplace de miles de vendedoresModulos, herramientas, mecanica, prototipos
Adafruit / SparkFunModulos bien documentados con bibliotecas propiasCuando el tiempo de integracion importa mas que el precio

La diferencia entre un distribuidor autorizado (Mouser, DigiKey, Arrow, Farnell) y un marketplace es la cadena de custodia. El distribuidor autorizado compra al fabricante y no ha tocado nadie mas la pieza. En un marketplace, un chip STM32 puede ser genuino, puede ser un lote de descarte fuera de especificacion, o puede ser un chip distinto con la superficie lijada y remarcada. Para un LED da igual. Para el microcontrolador de un dispositivo que va a estar tres años en la casa de otra persona, no.

Comprar en AliExpress con criterio

Reglas que evitan casi todos los problemas:

  • Suma el envio antes de comparar y consolida el carro. Un componente a USD 0.80 con envio de USD 4.20 es mas caro que el mismo a USD 3.00 con envio gratis, y diez pedidos separados al mismo vendedor pagan diez envios.
  • Considera el impuesto. Segun la normativa chilena vigente al momento de escribir este capitulo, desde octubre de 2025 las compras internacionales bajo USD 500 pagan IVA (19%). El calculo mental de “es barato porque no paga impuesto” ya no aplica.
  • Plazos reales de 2 a 6 semanas y sin garantia legal chilena. Planifica con el plazo largo; la disputa es con la plataforma, en ingles y con sus propios plazos.
  • Graba la apertura del paquete en un video continuo, sin cortes, mostrando la etiqueta antes de abrir. Es la unica evidencia que sirve en una disputa.
  • Revisa al vendedor, no al producto: años de operacion, cantidad de ordenes, evaluaciones con foto, y si esta especializado o vende de todo un poco.
  • Compra dos. Si el componente es central, el segundo cuesta una fraccion y te ahorra un mes de espera cuando el primero llegue muerto.

Calcular el costo real de una BOM en Elixir

Este modulo hace el calculo completo: precio, envio prorrateado, impuesto y unidades de repuesto. Es codigo Elixir puro y se ejecuta con elixir bom.exs.

# bom.exs
defmodule BOM do
  @moduledoc """
  Calcula el costo total de aterrizaje de una lista de materiales.
  Todos los montos en la misma moneda (por ejemplo USD).
  """

  # origen: :local | :importado
  defstruct [:ref, :descripcion, :cantidad, :precio_unitario, :repuestos, :origen]

  @iva 0.19

  @doc "Unidades a comprar realmente, incluyendo repuestos."
  def unidades(%__MODULE__{cantidad: c, repuestos: r}), do: c + r

  @doc "Subtotal de una linea, sin envio ni impuestos."
  def subtotal(%__MODULE__{precio_unitario: p} = item), do: unidades(item) * p

  @doc """
  Costo total de la BOM.

  Opciones:
    * `:envio_importado` - costo fijo de envio del pedido importado
    * `:envio_local`     - costo fijo de despacho local
    * `:iva_importacion` - si `true`, aplica IVA sobre bienes + envio importado
  """
  def total(items, opts \\ []) do
    envio_imp = Keyword.get(opts, :envio_importado, 0.0)
    envio_loc = Keyword.get(opts, :envio_local, 0.0)
    aplica_iva = Keyword.get(opts, :iva_importacion, true)

    {importado, local} = Enum.split_with(items, &(&1.origen == :importado))

    bienes_imp = Enum.reduce(importado, 0.0, &(subtotal(&1) + &2))
    bienes_loc = Enum.reduce(local, 0.0, &(subtotal(&1) + &2))

    base_imp = bienes_imp + envio_imp
    impuesto = if aplica_iva and base_imp > 0, do: base_imp * @iva, else: 0.0

    %{
      bienes_importados: redondear(bienes_imp),
      bienes_locales: redondear(bienes_loc),
      envio_importado: redondear(envio_imp),
      envio_local: redondear(envio_loc),
      impuesto_importacion: redondear(impuesto),
      total: redondear(base_imp + impuesto + bienes_loc + envio_loc)
    }
  end

  @doc "Imprime cada linea y el resumen final."
  def imprimir(items, opts \\ []) do
    Enum.each(items, fn i ->
      IO.puts("#{String.pad_trailing(i.ref, 8)} #{String.pad_trailing(i.descripcion, 30)} " <>
              "#{String.pad_leading("#{unidades(i)}", 4)} uds  " <>
              "subtotal #{Float.round(subtotal(i), 2)}")
    end)

    IO.puts(String.duplicate("-", 60))

    items
    |> total(opts)
    |> Enum.each(fn {k, v} -> IO.puts("#{String.pad_trailing(to_string(k), 24)} #{v}") end)
  end

  defp redondear(n), do: Float.round(n * 1.0, 2)
end

lista = [
  %BOM{ref: "U1", descripcion: "ESP32-WROOM-32E", cantidad: 1, precio_unitario: 3.40,
       repuestos: 1, origen: :importado},
  %BOM{ref: "U2", descripcion: "AMS1117-3.3", cantidad: 1, precio_unitario: 0.09,
       repuestos: 4, origen: :importado},
  %BOM{ref: "R1-R6", descripcion: "Resistencia 10k 0805", cantidad: 6, precio_unitario: 0.01,
       repuestos: 20, origen: :importado},
  %BOM{ref: "C1-C4", descripcion: "Capacitor 100nF 0805", cantidad: 4, precio_unitario: 0.01,
       repuestos: 20, origen: :importado},
  %BOM{ref: "J1", descripcion: "Conector USB-C", cantidad: 1, precio_unitario: 0.22,
       repuestos: 2, origen: :importado},
  %BOM{ref: "CAJA", descripcion: "Filamento PETG (fraccion)", cantidad: 1, precio_unitario: 1.50,
       repuestos: 0, origen: :local},
  %BOM{ref: "PCB", descripcion: "Lote 5 placas 2 capas", cantidad: 1, precio_unitario: 2.00,
       repuestos: 0, origen: :importado}
]

BOM.imprimir(lista, envio_importado: 8.50, envio_local: 3.00, iva_importacion: true)

Ejecutalo y vas a ver algo que sorprende a casi todo el mundo: en un prototipo pequeño, el envio y el impuesto suelen superar el valor de los componentes. Esa es la razon por la que se compra en lotes grandes y con repuestos.

Sistemas operativos de tiempo real

Cambiamos de tema y bajamos al firmware. Hasta ahora, en los capitulos anteriores, el codigo del microcontrolador vivio en un loop() que hacia todo en orden. Eso funciona hasta que el sistema tiene que hacer tres cosas con exigencias distintas al mismo tiempo.

Que significa “tiempo real”

Tiempo real no significa rapido: significa predecible. Un sistema de tiempo real garantiza que una tarea termina antes de un plazo (deadline) conocido, siempre, incluso en el peor caso; uno que no es de tiempo real garantiza que la tarea termina en algun momento, y en promedio lo hace rapido. Se distinguen tres grados segun lo que ocurre al perder el plazo.

GradoQue pasa si se pierde el plazoEjemplo
Duro (hard)Falla catastrofica del sistemaControl de un actuador de vuelo, disparo de un airbag, conmutacion de un inversor
Firme (firm)El resultado ya no sirve, pero no hay dañoUn cuadro de video que llega tarde se descarta
Blando (soft)Degradacion de calidadUn teclado que responde en 200 ms molesta, no falla

La mayoria de los proyectos de robotica e IoT mezclan los tres: el lazo de control de un motor es duro, la transmision de telemetria es blanda.

La maquina de estados de una tarea

Un RTOS gestiona tareas (o hilos) que transitan por estados bien definidos. Entender este diagrama resuelve el 80% de los bugs de concurrencia en firmware.

stateDiagram-v2
    [*] --> Lista: xTaskCreate()

    Lista --> Ejecutando: el planificador la elige<br/>(prioridad mas alta lista)
    Ejecutando --> Lista: la desaloja una tarea<br/>de mayor prioridad (preemption)
    Ejecutando --> Bloqueada: espera con plazo<br/>vTaskDelay, cola vacia,<br/>semaforo tomado
    Bloqueada --> Lista: llega el dato, vence el plazo<br/>o se libera el semaforo
    Ejecutando --> Suspendida: vTaskSuspend()
    Suspendida --> Lista: vTaskResume()
    Ejecutando --> [*]: vTaskDelete()

    note right of Bloqueada
        Bloqueada NO consume CPU.
        Aqui es donde el sistema
        ahorra energia y deja
        correr a las demas tareas.
    end note

    note right of Ejecutando
        En un nucleo solo hay
        UNA tarea Ejecutando.
        En dos nucleos, dos.
    end note

La distincion clave es bloqueada frente a espera activa. Un delay(100) que gira en un bucle contando ciclos deja el procesador ocupado sin hacer nada. Un vTaskDelay(pdMS_TO_TICKS(100)) marca la tarea como bloqueada, el planificador corre otras, y si no hay ninguna lista, el chip entra en bajo consumo. La diferencia se mide en horas de bateria.

Los tres numeros que definen un RTOS

  • Latencia de interrupcion: cuanto tarda el sistema, en el peor caso, desde que llega la señal electrica hasta que se ejecuta la primera instruccion de la rutina de atencion. Se mide en microsegundos o en ciclos.
  • Latencia de cambio de contexto: cuanto tarda en guardar los registros de una tarea y cargar los de otra.
  • Jitter: la variacion entre el mejor y el peor caso. Un sistema con latencia promedio de 5 us y peor caso de 400 us es peor, para control, que uno con promedio de 40 us y peor caso de 45 us.

Existe ademas un problema clasico llamado inversion de prioridad: una tarea de baja prioridad toma un mutex que necesita una de alta prioridad; una de prioridad media desaloja a la baja; resultado: la de alta prioridad espera indefinidamente por culpa de una de prioridad media. Es exactamente lo que casi mata la mision Mars Pathfinder en 1997. La solucion, que FreeRTOS y Zephyr implementan, se llama herencia de prioridad: mientras la tarea baja tiene el mutex, hereda temporalmente la prioridad de la mas alta que lo espera.

FreeRTOS

Kernel muy compacto escrito en C, con licencia MIT, portado a mas de cuarenta arquitecturas de microcontrolador. Es el RTOS que ESP-IDF trae integrado, por lo tanto es el que ya estas usando aunque no lo sepas si programaste un ESP32. Ofrece tareas, colas, semaforos, mutex con herencia de prioridad, temporizadores por software, grupos de eventos y notificaciones directas a tarea.

Ejemplo completo para ESP-IDF: una tarea lee un sensor cada 500 ms y envia el valor por una cola; otra tarea consume la cola y decide encender un LED. Sin variables globales compartidas, sin mutex, sin condiciones de carrera.

// main/main.c  (ESP-IDF, ESP32)
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_random.h"

#define LED_GPIO      GPIO_NUM_2
#define UMBRAL_ALERTA 700

static const char *TAG = "sensor";
static QueueHandle_t cola_lecturas;

typedef struct {
    uint32_t marca_ms;
    uint16_t valor;
} lectura_t;

/* Productor: prioridad media, cadencia fija de 500 ms. */
static void tarea_sensor(void *param)
{
    TickType_t proximo = xTaskGetTickCount();

    for (;;) {
        lectura_t l = {
            .marca_ms = xTaskGetTickCount() * portTICK_PERIOD_MS,
            /* Sustituir por adc_oneshot_read() con hardware real. */
            .valor = (uint16_t)(esp_random() % 1024)
        };

        if (xQueueSend(cola_lecturas, &l, pdMS_TO_TICKS(10)) != pdTRUE) {
            ESP_LOGW(TAG, "cola llena, lectura descartada");
        }
        /* Cadencia estable: no acumula deriva como vTaskDelay simple. */
        vTaskDelayUntil(&proximo, pdMS_TO_TICKS(500));
    }
}

/* Consumidor: prioridad mas alta, se bloquea hasta que llega un dato. */
static void tarea_control(void *param)
{
    lectura_t l;

    for (;;) {
        if (xQueueReceive(cola_lecturas, &l, portMAX_DELAY) == pdTRUE) {
            bool alerta = l.valor > UMBRAL_ALERTA;
            gpio_set_level(LED_GPIO, alerta ? 1 : 0);
            ESP_LOGI(TAG, "t=%lu ms valor=%u %s", (unsigned long)l.marca_ms,
                     l.valor, alerta ? "ALERTA" : "ok");
        }
    }
}

/* Vigilante: prioridad baja, reporta memoria libre y espacio de pila. */
static void tarea_diagnostico(void *param)
{
    for (;;) {
        ESP_LOGI(TAG, "heap libre: %u bytes | pila libre: %u palabras",
                 (unsigned)esp_get_free_heap_size(),
                 (unsigned)uxTaskGetStackHighWaterMark(NULL));
        vTaskDelay(pdMS_TO_TICKS(5000));
    }
}

void app_main(void)
{
    gpio_config_t cfg = {
        .pin_bit_mask = (1ULL << LED_GPIO),
        .mode = GPIO_MODE_OUTPUT,
        .pull_up_en = GPIO_PULLUP_DISABLE,
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type = GPIO_INTR_DISABLE
    };
    gpio_config(&cfg);

    cola_lecturas = xQueueCreate(8, sizeof(lectura_t));
    if (cola_lecturas == NULL) {
        ESP_LOGE(TAG, "no se pudo crear la cola");
        return;
    }

    /* xTaskCreate(funcion, nombre, palabras de pila, param, prioridad, handle) */
    xTaskCreate(tarea_control,     "control",  3072, NULL, 6, NULL);
    xTaskCreate(tarea_sensor,      "sensor",   3072, NULL, 5, NULL);
    xTaskCreate(tarea_diagnostico, "diag",     3072, NULL, 1, NULL);
}

Tres detalles que valen mas que el resto del ejemplo. vTaskDelayUntil mantiene una cadencia absoluta, mientras que vTaskDelay espera “500 ms desde ahora” y ese “ahora” incluye lo que demoro el trabajo previo, por lo que la cadencia deriva; para muestreo periodico se usa el primero. portMAX_DELAY en xQueueReceive bloquea indefinidamente, y la tarea consumidora no gasta nada de CPU mientras espera. uxTaskGetStackHighWaterMark devuelve el minimo de pila libre que ha tenido la tarea: es la herramienta para dimensionar pilas, porque si nunca baja de 1500 palabras sobra memoria, y si baja de 200 estas a un printf de un desbordamiento.

Zephyr

Proyecto de la Linux Foundation, con licencia Apache 2.0. Es mucho mas que un kernel: es un sistema operativo completo para embebidos con mas de cuatrocientas placas soportadas, pila de red (TCP/IP, IPv6, 6LoWPAN, Bluetooth LE, CAN), sistema de archivos, gestor de actualizaciones (MCUboot), arranque seguro y un modelo de drivers unificado.

Su rasgo distintivo es el devicetree: el hardware se describe en archivos declarativos .dts separados del codigo de la aplicacion, de modo que el mismo codigo compila para otra placa cambiando un parametro de compilacion. Junto con Kconfig para la configuracion de funcionalidades, hace que Zephyr se sienta mucho mas cerca de Linux que de Arduino.

/* src/main.c  (Zephyr) */
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/logging/log.h>

LOG_MODULE_REGISTER(demo, LOG_LEVEL_INF);

/* El alias led0 lo define el devicetree de la placa: el codigo no
   conoce el numero de pin, solo el rol. */
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios);

#define TAM_PILA 1024
#define PRIO_PRODUCTOR 5
#define PRIO_CONSUMIDOR 4   /* numero menor = mayor prioridad en Zephyr */

struct lectura {
    uint32_t marca_ms;
    uint16_t valor;
};

/* Cola de mensajes: 8 elementos, alineada a 4 bytes. */
K_MSGQ_DEFINE(cola_lecturas, sizeof(struct lectura), 8, 4);

static void productor(void *a, void *b, void *c)
{
    ARG_UNUSED(a); ARG_UNUSED(b); ARG_UNUSED(c);
    uint16_t simulado = 0;

    while (1) {
        struct lectura l = { .marca_ms = k_uptime_get_32(), .valor = simulado };
        simulado = (simulado + 137) % 1024;

        if (k_msgq_put(&cola_lecturas, &l, K_MSEC(10)) != 0) {
            LOG_WRN("cola llena, se descarta la muestra");
        }
        k_sleep(K_MSEC(500));
    }
}

static void consumidor(void *a, void *b, void *c)
{
    ARG_UNUSED(a); ARG_UNUSED(b); ARG_UNUSED(c);
    struct lectura l;

    while (1) {
        /* K_FOREVER bloquea sin consumir CPU. */
        if (k_msgq_get(&cola_lecturas, &l, K_FOREVER) == 0) {
            bool alerta = l.valor > 700;
            gpio_pin_set_dt(&led, alerta ? 1 : 0);
            LOG_INF("t=%u ms valor=%u %s", l.marca_ms, l.valor, alerta ? "ALERTA" : "ok");
        }
    }
}

K_THREAD_DEFINE(hilo_prod, TAM_PILA, productor, NULL, NULL, NULL,
                PRIO_PRODUCTOR, 0, 0);
K_THREAD_DEFINE(hilo_cons, TAM_PILA, consumidor, NULL, NULL, NULL,
                PRIO_CONSUMIDOR, 0, 0);

int main(void)
{
    if (!gpio_is_ready_dt(&led)) {
        LOG_ERR("el GPIO del LED no esta listo");
        return -1;
    }
    gpio_pin_configure_dt(&led, GPIO_OUTPUT_INACTIVE);
    LOG_INF("sistema iniciado");
    return 0;
}

Se compila y se graba con west:

# entorno ya inicializado con west init / west update
west build -b esp32_devkitc_wroom/esp32/procpu -p auto .
west flash && west espressif monitor   # o el monitor de tu placa

Nota importante sobre prioridades: en FreeRTOS, numero mayor significa mayor prioridad. En Zephyr es al reves para los hilos cooperativos y preemptivos: numero menor significa mayor prioridad, y los numeros negativos son hilos cooperativos que no pueden ser desalojados. Confundir esas dos convenciones es un error habitual al migrar codigo.

Los demas: ThreadX, NuttX, RT-Thread, VxWorks, Mbed

  • Eclipse ThreadX: nacio como ThreadX de Express Logic, paso a ser Azure RTOS bajo Microsoft y hoy lo mantiene la Eclipse Foundation con licencia permisiva. Su arquitectura picokernel elimina el anidamiento de capas: cada servicio accede directo al nucleo en vez de llamarse en cadena. Tiene certificaciones de seguridad funcional (IEC 61508, IEC 62304, ISO 26262) que importan si el producto es medico o automotriz.
  • Apache NuttX: su rasgo es la conformidad con POSIX. Programas escritos para Linux compilan con cambios minimos, incluyendo pthread, open/read/write, sockets BSD y hasta una consola de shell. Es el RTOS del piloto automatico PX4 de drones.
  • RT-Thread: de origen chino, muy popular en Asia. Su fuerte es un gestor de paquetes con miles de componentes que se agregan por menu, y una version Nano que cabe en 3 KB.
  • VxWorks: propietario, de Wind River. Es el RTOS de los sistemas donde no hay segunda oportunidad: instrumentacion del Boeing 787, rovers de la NASA, equipamiento medico. Su historial de certificaciones no lo iguala ningun proyecto libre, y su precio tampoco.
  • Mbed OS: la apuesta de Arm para Cortex-M. El proyecto oficial esta en fin de vida y su soporte termina en julio de 2026; existe una bifurcacion comunitaria (Mbed CE). No es una base recomendable para un proyecto nuevo.

Comparacion de RTOS

RTOSLicenciaHuella tipicaFuerteCuando elegirlo
FreeRTOSMITDesde ~6-12 KB de flashSimplicidad, ubicuidad, base de ESP-IDFProyectos con ESP32, o cuando quieres el kernel minimo y nada mas
ZephyrApache 2.0Desde ~8 KB, crece con los subsistemasSO completo: red, BLE, drivers, OTA, devicetreeProducto conectado, multiples placas, actualizacion remota
Eclipse ThreadXPermisivaMuy compactaDeterminismo, certificaciones de seguridadDispositivos regulados: medico, automotriz, industrial
Apache NuttXApache 2.0MediaPOSIX real, shell, sistema de archivosPortar codigo de Linux; drones (PX4)
RT-ThreadApache 2.0Nano desde ~3 KBGestor de paquetes, ecosistema asiaticoCuando el catalogo de componentes ahorra semanas
VxWorksPropietarioMedia-altaCertificaciones, soporte industrialAeroespacial, defensa, medico critico
Mbed OSApache 2.0MediaHistoria y ejemplos en Cortex-MSolo mantenimiento; no iniciar proyectos nuevos

Donde encaja el BEAM en todo esto

Aqui es donde este curso se separa del camino habitual. Elixir corre sobre la maquina virtual BEAM, y BEAM tiene su propio planificador preemptivo con millones de procesos ligeros. En un microcontrolador, AtomVM implementa esa semantica sobre el RTOS del chip.

flowchart TD
    subgraph HW["Hardware ESP32"]
        CPU["2 nucleos Xtensa LX6"]
        PER["Perifericos: GPIO, ADC, SPI, I2C, WiFi"]
    end

    subgraph RTOS["FreeRTOS (dentro de ESP-IDF)"]
        T1["Tarea: pila WiFi"]
        T2["Tarea: AtomVM"]
        T3["Tarea: temporizadores"]
    end

    subgraph VM["AtomVM dentro de la tarea T2"]
        S["Planificador del BEAM"]
        P1["proceso: lectura sensor"]
        P2["proceso: control actuador"]
        P3["proceso: cliente MQTT"]
        SUP["supervisor"]
        S --> P1
        S --> P2
        S --> P3
        SUP -. reinicia .-> P1
        SUP -. reinicia .-> P2
        SUP -. reinicia .-> P3
    end

    CPU --> RTOS
    PER --> RTOS
    T2 --> VM
    N1["RTOS: pocas tareas, pilas fijas,<br/>prioridades numericas"] -.-> RTOS
    N2["BEAM: muchos procesos baratos,<br/>aislados y supervisados"] -.-> VM

Son dos capas de concurrencia apiladas, y cada una resuelve un problema distinto. El RTOS garantiza el determinismo temporal del hardware: la interrupcion del WiFi se atiende a tiempo pase lo que pase. El BEAM aporta aislamiento y tolerancia a fallas: si el proceso que habla con el sensor de temperatura muere porque el sensor se desconecto, el supervisor lo reinicia y el resto del sistema ni se entera.

La consecuencia practica: el BEAM no es adecuado para tiempo real duro. No pongas un lazo de control de motor a 20 kHz dentro de un proceso de Elixir. Ese lazo va en una tarea de FreeRTOS de alta prioridad, o directamente en hardware con un temporizador y PWM. Elixir se lleva la logica de aplicacion, la maquina de estados, la comunicacion y la supervision, que es donde su modelo brilla y donde C es doloroso.

ROS: el Robot Operating System

Que es y que no es

ROS no es un sistema operativo. Es un middleware: un conjunto de bibliotecas, convenciones y herramientas para que muchos programas independientes se comuniquen entre si formando un robot. Corre encima de Linux (principalmente Ubuntu), y tambien sobre Windows, macOS y, con micro-ROS, sobre RTOS en microcontroladores.

Lo que ROS aporta que uno no quiere reescribir:

  • Transporte de mensajes con tipos definidos, descubrimiento automatico y politicas de calidad de servicio; y drivers ya escritos para cientos de sensores: LiDAR, camaras de profundidad, IMU, GPS, encoders.
  • Algoritmos de navegacion (Nav2), manipulacion (MoveIt), localizacion y mapeo (SLAM Toolbox) y percepcion.
  • Herramientas de visualizacion (RViz), simulacion (Gazebo), grabacion y reproduccion de sesiones (ros2 bag) e introspeccion en vivo del grafo de nodos.
  • Convenciones: sistemas de coordenadas, unidades, nombres de topicos, transformadas entre marcos de referencia (tf2).

Esa ultima linea es la mas subestimada: que todo el mundo acuerde que las coordenadas son X hacia adelante, Y hacia la izquierda, Z hacia arriba, en metros y radianes, es lo que hace que un LiDAR de una marca y un planificador de otro autor funcionen juntos sin adaptadores.

ROS 1 frente a ROS 2

AspectoROS 1ROS 2
ArquitecturaCentralizada: un roscore maestroDescentralizada: sin nodo maestro
TransporteTCPROS/UDPROS propioDDS estandar (Fast DDS, Cyclone DDS, Connext)
DescubrimientoContra el maestroAutomatico entre pares en la red
Tiempo realNo contemplado en el diseñoSoporte explicito, ejecutores configurables
Sistemas operativosLinux principalmenteLinux, Windows, macOS, RTOS (micro-ROS)
SeguridadNinguna en el nucleoSROS 2: autenticacion, cifrado, control de acceso
Punto unico de fallaSi, el maestroNo
EstadoFin de soporte (Noetic terminado en 2025)Version activa y recomendada

Para cualquier proyecto nuevo la respuesta es ROS 2. ROS 1 solo aparece al mantener sistemas existentes.

El grafo de nodos

La unidad de ROS es el nodo: un proceso que hace una cosa. Los nodos se comunican de tres maneras: topicos, servicios y acciones.

flowchart LR
    LID["Nodo: driver LiDAR"] -- topico /scan<br/>sensor_msgs/LaserScan --> SLAM["Nodo: SLAM"]
    ENC["Nodo: driver ruedas"] -- topico /odom<br/>nav_msgs/Odometry --> SLAM
    IMU["Nodo: IMU"] -- topico /imu --> SLAM

    SLAM -- topico /map<br/>nav_msgs/OccupancyGrid --> NAV["Nodo: planificador Nav2"]
    SLAM -- topico /tf --> NAV

    UI["Interfaz de usuario"] -- accion NavigateToPose<br/>meta + feedback + resultado --> NAV
    NAV -- topico /cmd_vel<br/>geometry_msgs/Twist --> BASE["Nodo: control de base"]
    BASE -- servicio /reset_odom<br/>peticion / respuesta --> ENC
    BASE --> MOT["Motores"]

    PARAM["Servidor de parametros<br/>de cada nodo"] -.-> NAV
    PARAM -.-> SLAM
MecanismoPatronBloqueaUsar para
TopicoPublicacion / suscripcion, muchos a muchos, asincronoNoFlujos continuos: sensores, comandos de velocidad, estado
ServicioPeticion / respuesta, uno a uno, sincronoSi, hasta la respuestaOperaciones rapidas y puntuales: leer configuracion, activar un modo
AccionMeta con retroalimentacion periodica, cancelableNoTareas largas: navegar a un punto, cerrar una pinza, cargar bateria

El error mas frecuente del principiante es usar un servicio para algo que demora treinta segundos: bloquea al cliente, no se puede cancelar y no informa progreso. Para eso existen las acciones.

Calidad de servicio (QoS): la parte que nadie explica

Como ROS 2 corre sobre DDS, cada publicador y cada suscriptor declara politicas de QoS, y si no coinciden simplemente no se comunican, sin dar error. Esta es la causa numero uno de “publico pero no llega nada”.

PoliticaOpcionesEfecto
FiabilidadRELIABLE / BEST_EFFORTFiable retransmite hasta confirmar; mejor esfuerzo descarta. Video y LiDAR suelen ir en mejor esfuerzo
DurabilidadVOLATILE / TRANSIENT_LOCALTransient local entrega los ultimos mensajes a un suscriptor que llega tarde. Se usa para el mapa y para datos estaticos
HistorialKEEP_LAST(n) / KEEP_ALLCuantos mensajes se guardan en el buffer
PlazoduracionAlerta si un topico deja de publicar a la cadencia comprometida
Vitalidadautomatica / manualDetecta que un publicador murio

Regla de compatibilidad: un suscriptor RELIABLE no recibe de un publicador BEST_EFFORT; al reves si funciona. Cuando algo no llega, el primer comando a ejecutar es ros2 topic info /mi_topico --verbose, que muestra la QoS de ambos extremos.

Un nodo ROS 2 completo en Python

#!/usr/bin/env python3
# vigilante_bateria.py
# Nodo ROS 2 que publica el nivel de bateria y frena el robot si baja del umbral.

import rclpy
from rclpy.node import Node
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy
from sensor_msgs.msg import BatteryState
from geometry_msgs.msg import Twist


class VigilanteBateria(Node):
    def __init__(self):
        super().__init__('vigilante_bateria')

        # Parametros declarados: se cambian en caliente con
        # ros2 param set /vigilante_bateria umbral_critico 0.15
        self.declare_parameter('umbral_critico', 0.20)
        self.declare_parameter('periodo_s', 1.0)

        qos_sensor = QoSProfile(
            reliability=ReliabilityPolicy.BEST_EFFORT,
            history=HistoryPolicy.KEEP_LAST,
            depth=5,
        )
        self.pub_bateria = self.create_publisher(BatteryState, 'battery_state', qos_sensor)
        self.pub_cmd = self.create_publisher(Twist, 'cmd_vel', 10)
        self.sub_cmd = self.create_subscription(Twist, 'cmd_vel_raw', self.on_cmd, 10)
        self.timer = self.create_timer(self.get_parameter('periodo_s').value, self.on_timer)

        self.carga = 1.0
        self.bloqueado = False
        self.get_logger().info('vigilante de bateria iniciado')

    def on_timer(self):
        # Sustituir por lectura real del BMS por I2C o UART.
        self.carga = max(0.0, self.carga - 0.01)

        msg = BatteryState()
        msg.header.stamp = self.get_clock().now().to_msg()
        msg.percentage = float(self.carga)
        msg.voltage = 11.1 + 1.5 * float(self.carga)
        msg.present = True
        self.pub_bateria.publish(msg)

        umbral = self.get_parameter('umbral_critico').value
        if self.carga < umbral and not self.bloqueado:
            self.bloqueado = True
            self.get_logger().warn(f'bateria en {self.carga:.0%}: frenando')
            self.pub_cmd.publish(Twist())  # todo en cero = detener

    def on_cmd(self, msg: Twist):
        """Filtro: deja pasar los comandos solo si la bateria alcanza."""
        self.pub_cmd.publish(Twist() if self.bloqueado else msg)


def main(args=None):
    rclpy.init(args=args)
    nodo = VigilanteBateria()
    try:
        rclpy.spin(nodo)
    except KeyboardInterrupt:
        pass
    finally:
        nodo.destroy_node()
        rclpy.shutdown()


if __name__ == '__main__':
    main()

Y los comandos de introspeccion que se usan todos los dias:

ros2 node list                     # nodos activos
ros2 topic list -t                 # topicos y su tipo
ros2 topic echo /battery_state     # escuchar un topico en vivo
ros2 topic hz /scan                # frecuencia real de publicacion
ros2 topic info /cmd_vel --verbose # QoS de ambos extremos: el diagnostico clave

# publicar a mano para probar un actuador
ros2 topic pub --once /cmd_vel geometry_msgs/msg/Twist \
  "{linear: {x: 0.2}, angular: {z: 0.0}}"

# cambiar un parametro sin reiniciar el nodo
ros2 param set /vigilante_bateria umbral_critico 0.15

# grabar y reproducir una sesion completa
ros2 bag record -a -o sesion_prueba
ros2 bag play sesion_prueba

micro-ROS: ROS 2 dentro del microcontrolador

Un ESP32 no puede correr DDS completo ni un nodo de ROS 2 estandar: no tiene la memoria ni el sistema operativo. micro-ROS resuelve eso con una arquitectura de cliente y agente:

  • En el microcontrolador corre el cliente, que usa una implementacion reducida llamada XRCE-DDS (eXtremely Resource Constrained Environment DDS) y la biblioteca rclc en C.
  • En un computador o SBC con Linux corre el agente (micro_ros_agent), que traduce entre XRCE-DDS y el DDS completo de la red ROS 2.

Desde el punto de vista del resto del sistema, el microcontrolador aparece como un nodo mas del grafo.

sequenceDiagram
    participant MCU as ESP32<br/>(FreeRTOS + micro-ROS client)
    participant SER as Enlace<br/>serial / UDP / CAN
    participant AG as micro_ros_agent<br/>(Linux)
    participant DDS as Red DDS<br/>de ROS 2
    participant NAV as Nodo Nav2

    MCU->>SER: sesion XRCE-DDS: create_session
    SER->>AG: trama serializada
    AG->>DDS: registra el nodo /esp32_sensores
    DDS-->>NAV: descubrimiento: nuevo publicador /scan_ir

    loop cada 100 ms
        MCU->>SER: publish(/scan_ir, Range)
        SER->>AG: paquete
        AG->>DDS: mensaje DDS con QoS
        DDS->>NAV: entrega
    end

    NAV->>DDS: publish(/cmd_motor, Twist)
    DDS->>AG: mensaje
    AG->>SER: trama XRCE-DDS
    SER->>MCU: callback del executor rclc
    MCU->>MCU: aplica PWM al motor

Publicador minimo con rclc, la API en C de micro-ROS:

// micro-ROS: publicador de distancia sobre ESP32 / FreeRTOS
#include <rcl/rcl.h>
#include <rcl/error_handling.h>
#include <rclc/rclc.h>
#include <rclc/executor.h>
#include <sensor_msgs/msg/range.h>

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

static rcl_publisher_t publicador;
static sensor_msgs__msg__Range mensaje;

static void callback_timer(rcl_timer_t *timer, int64_t ultimo)
{
    (void)ultimo;
    if (timer == NULL) return;

    /* Sustituir por la lectura real del sensor ultrasonico. */
    mensaje.range = 0.42f;
    mensaje.min_range = 0.02f;
    mensaje.max_range = 4.00f;
    mensaje.field_of_view = 0.26f;
    mensaje.radiation_type = sensor_msgs__msg__Range__ULTRASOUND;

    rcl_publish(&publicador, &mensaje, NULL);
}

void tarea_microros(void *arg)
{
    rcl_allocator_t allocator = rcl_get_default_allocator();
    rclc_support_t support;
    rcl_node_t nodo;
    rcl_timer_t timer;
    rclc_executor_t executor;

    rclc_support_init(&support, 0, NULL, &allocator);
    rclc_node_init_default(&nodo, "esp32_sensores", "", &support);
    rclc_publisher_init_default(&publicador, &nodo,
        ROSIDL_GET_MSG_TYPE_SUPPORT(sensor_msgs, msg, Range), "scan_ir");
    rclc_timer_init_default(&timer, &support, RCL_MS_TO_NS(100), callback_timer);
    rclc_executor_init(&executor, &support.context, 1, &allocator);
    rclc_executor_add_timer(&executor, &timer);

    while (1) {
        rclc_executor_spin_some(&executor, RCL_MS_TO_NS(100));
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

Y el agente, del lado del computador:

# por serial, o por red si el microcontrolador publica por WiFi
ros2 run micro_ros_agent micro_ros_agent serial --dev /dev/ttyUSB0 -b 115200
ros2 run micro_ros_agent micro_ros_agent udp4 --port 8888

# desde otra terminal, el nodo del ESP32 aparece como cualquier otro
ros2 node list
ros2 topic echo /scan_ir

Elixir y ROS

Hay tres formas realistas de conectar el mundo BEAM con el mundo ROS.

1. Rclex: una biblioteca de cliente de ROS 2 para Elixir que envuelve rcl mediante NIFs, permitiendo crear nodos, publicadores y suscriptores directamente desde Elixir, con los procesos de OTP como unidad de ejecucion. Es la opcion mas integrada. Su API ha cambiado entre versiones mayores, asi que hay que seguir la documentacion de la version exacta que instales; el patron general es iniciar un nodo, iniciar un publicador asociado a un tipo de mensaje y a un topico, y publicar. Requiere tener ROS 2 instalado en la maquina, porque compila contra sus bibliotecas.

2. Beam Bots: propuesta del ecosistema Elixir que plantea usar directamente OTP —procesos, supervisores, GenServer, distribucion Erlang— como sustrato de orquestacion de un robot, en lugar de adoptar ROS. La idea de fondo es que buena parte de lo que ROS construye a mano (descubrimiento, tolerancia a fallas, comunicacion entre nodos, ejecucion distribuida) el BEAM ya lo trae desde 1986. La contrapartida es que se renuncia al ecosistema de algoritmos y drivers de ROS, que es enorme.

3. Puente por protocolo neutro: mantener ROS 2 donde ROS 2 es imbatible (percepcion, navegacion, simulacion) y Elixir donde Elixir es imbatible (logica de negocio, supervision, telemetria, interfaz web con Phoenix), comunicandolos con un puente sencillo: MQTT, WebSocket, UDP con JSON, o rosbridge_suite. Es la arquitectura mas comun en la practica porque no obliga a ningun equipo a cambiar de lenguaje.

El siguiente ejemplo implementa el puente en su forma mas simple y sin dependencias externas: un GenServer supervisado que habla UDP con JSON contra un nodo de ROS 2, con supervision y reintento.

# lib/puente_ros/enlace.ex
defmodule PuenteRos.Enlace do
  @moduledoc """
  Puente UDP entre el mundo Elixir y un nodo puente de ROS 2.

  El nodo de ROS 2 del otro lado escucha JSON en un puerto UDP,
  lo republica en un topico, y reenvia hacia aca lo que llega de ROS.
  """
  use GenServer
  require Logger

  @nombre __MODULE__

  def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: @nombre)

  @doc "Envia un comando de velocidad al robot."
  def mover(lineal_x, angular_z) do
    GenServer.cast(@nombre, {:enviar, %{
      "topico" => "/cmd_vel",
      "tipo" => "geometry_msgs/msg/Twist",
      "datos" => %{
        "linear" => %{"x" => lineal_x, "y" => 0.0, "z" => 0.0},
        "angular" => %{"x" => 0.0, "y" => 0.0, "z" => angular_z}
      }
    }})
  end

  def detener, do: mover(0.0, 0.0)

  @doc "Ultimo estado conocido del robot, por topico."
  def estado, do: GenServer.call(@nombre, :estado)

  @impl true
  def init(opts) do
    destino_ip = Keyword.get(opts, :ip, {127, 0, 0, 1})
    destino_puerto = Keyword.get(opts, :puerto_destino, 9091)
    puerto_local = Keyword.get(opts, :puerto_local, 9090)

    {:ok, socket} = :gen_udp.open(puerto_local, [:binary, active: true, reuseaddr: true])
    Logger.info("puente ROS escuchando en UDP #{puerto_local}")

    {:ok, %{socket: socket, destino: {destino_ip, destino_puerto}, ultimo: %{}}}
  end

  @impl true
  def handle_cast({:enviar, mapa}, state) do
    {ip, puerto} = state.destino

    case Jason.encode(mapa) do
      {:ok, json} -> :gen_udp.send(state.socket, ip, puerto, json)
      {:error, razon} -> Logger.error("no se pudo codificar: #{inspect(razon)}")
    end

    {:noreply, state}
  end

  @impl true
  def handle_call(:estado, _from, state), do: {:reply, state.ultimo, state}

  @impl true
  def handle_info({:udp, _socket, _ip, _puerto, datos}, state) do
    case Jason.decode(datos) do
      {:ok, %{"topico" => topico, "datos" => valores}} ->
        # Difundir a los interesados dentro del sistema Elixir.
        Phoenix.PubSub.broadcast(PuenteRos.PubSub, "ros:#{topico}", {:ros, topico, valores})
        {:noreply, %{state | ultimo: Map.put(state.ultimo, topico, valores)}}

      _ ->
        Logger.warning("mensaje descartado desde ROS")
        {:noreply, state}
    end
  end

  @impl true
  def terminate(_razon, state), do: :gen_udp.close(state.socket)
end

Con su arbol de supervision:

# lib/puente_ros/application.ex
defmodule PuenteRos.Application do
  use Application

  @impl true
  def start(_type, _args) do
    Supervisor.start_link(
      [
        {Phoenix.PubSub, name: PuenteRos.PubSub},
        {PuenteRos.Enlace, ip: {192, 168, 1, 40}, puerto_destino: 9091, puerto_local: 9090}
      ],
      strategy: :one_for_one, name: PuenteRos.Supervisor)
  end
end

Si el enlace se cae, el supervisor reinicia el GenServer, se reabre el socket y el sistema sigue. Esa propiedad —recuperarse de una falla parcial sin reiniciar el proceso completo— es exactamente lo que ROS resuelve con mucho mas trabajo y lo que el BEAM regala.

Como se combina todo en un proyecto real

flowchart TD
    A1[Esquematico en KiCad] --> A2[PCB ruteada]
    A2 --> A3[Export STEP de la placa]
    A3 --> A4[Carcasa parametrica<br/>alrededor del STEP real]
    A2 --> A5[Gerber + BOM]
    A5 --> B1[Cotizar BOM: local vs importado]
    B1 --> B2[Costo de aterrizaje con envio e IVA]
    B2 --> B3[Pedido con repuestos]
    A5 --> C1[Fabrica de PCB]
    A4 --> C2[Impresion 3D en PETG]
    C1 --> C3[Ensamblado y soldadura]
    B3 --> C3
    C2 --> C3
    C3 --> D1[FreeRTOS/Zephyr:<br/>lazos criticos y drivers]
    D1 --> D2[AtomVM: logica y<br/>supervision en Elixir]
    D2 --> D3[micro-ROS o MQTT]
    D3 --> E1[ROS 2 en SBC:<br/>percepcion y navegacion]
    E1 --> E2[Elixir/Phoenix:<br/>API, panel, telemetria]
    E2 --> E3[Grafana / Home Assistant<br/>del capitulo anterior]

Errores comunes

ErrorCausaSolucion
La carcasa impresa no calza con la placaSe modelo con medidas del datasheet, no de la placa realExportar STEP desde KiCad e importarlo al CAD mecanico; medir con pie de metro la placa fabricada antes de imprimir la version final
El agujero de 3 mm no acepta el tornillo M3El FDM contrae los agujeros por el ancho de extrusionModelar 3.3-3.5 mm para pasante; imprimir primero una pieza de calibracion con agujeros escalonados
La caja se deforma al lado del reguladorSe imprimio en PLA, que ablanda cerca de 55-60 °CReimprimir en PETG o ABS/ASA; separar la fuente de calor de la pared
El fabricante rechaza el gerberFalta la capa de contorno, o los taladros van mezcladosUsar el perfil de exportacion que el propio fabricante publica para tu herramienta EDA
El microcontrolador comprado en marketplace no respondeChip remarcado o de descarteComprar componentes criticos a distribuidor autorizado; reservar el marketplace para pasivos y mecanica
El firmware “se cuelga” al azarDesbordamiento de pila de una tarea del RTOSMedir con uxTaskGetStackHighWaterMark (FreeRTOS) o el analisis de pila de Zephyr y dimensionar con margen
Una tarea de alta prioridad nunca correOtra tarea de igual o mayor prioridad no se bloquea nunca (espera activa)Reemplazar bucles de espera por vTaskDelay, colas o semaforos que bloqueen de verdad
El sistema responde bien en promedio pero falla a vecesInversion de prioridad con un mutex compartidoUsar mutex con herencia de prioridad, no semaforos binarios
El codigo migrado de FreeRTOS a Zephyr invierte las prioridadesEn FreeRTOS numero mayor es mas prioritario; en Zephyr es al revesRevisar y remapear todas las prioridades al portar
En ROS 2 publico pero el suscriptor no recibe nadaQoS incompatible entre publicador y suscriptorros2 topic info /topico --verbose y alinear fiabilidad y durabilidad en ambos extremos
Un servicio de ROS 2 deja colgada la aplicacionSe uso un servicio para una tarea largaConvertirlo en una accion, que informa progreso y se puede cancelar
micro-ROS no aparece en ros2 node listEl agente no esta corriendo, o el transporte no coincideLevantar micro_ros_agent con el mismo transporte y velocidad que el firmware; revisar permisos del puerto serial
El lazo de control tiembla al ponerlo en ElixirSe puso tiempo real duro dentro del BEAMMover el lazo a una tarea de RTOS o a hardware (PWM por temporizador) y dejar en Elixir la logica y la supervision

Ejercicios propuestos

  1. Del esquematico al gerber. Diseña en KiCad o EasyEDA una placa de dos capas con un ESP32-C3, un regulador de 3.3 V, un conector USB-C, un pulsador de reset y tres LEDs. Corre ERC y DRC hasta que no quede ningun error y exporta el paquete completo de fabricacion. No necesitas mandarla a fabricar: el objetivo es completar el flujo.

  2. La carcasa parametrica. Toma el archivo carcasa_esp32.scad de este capitulo y agregale: una ranura lateral para un conector JST de bateria, un rebaje para pegar una guia de luz sobre un LED, y un parametro montaje que agregue orejas para tornillo a la pared cuando valga true. Genera las variantes desde la linea de comandos.

  3. Pieza de calibracion. Modela e imprime una placa de 50 x 50 mm con agujeros de 2.6, 2.8, 3.0, 3.2, 3.4 y 3.6 mm etiquetados en relieve. Prueba con tornillos M3 reales y anota que diametro modelado te da un pasante y cual un autorroscante. Usa esos valores en todos los proyectos siguientes.

  4. Costo real de aterrizaje. Arma la BOM de un proyecto tuyo y ejecuta el modulo BOM de Elixir con precios reales de dos fuentes distintas: una tienda chilena y un proveedor internacional. Compara el total con envio e impuesto incluidos y calcula a partir de que cantidad de unidades se invierte la conveniencia.

  5. Tres tareas con una cola. Implementa el ejemplo de FreeRTOS en un ESP32 real, reemplazando el valor aleatorio por una lectura de ADC. Reduce la cola de 8 a 2 elementos y observa cuando aparecen las advertencias de cola llena. Despues baja la pila de 3072 a 1024 palabras y usa uxTaskGetStackHighWaterMark para encontrar el minimo valor que aun funciona.

  6. El mismo programa en Zephyr. Porta el ejemplo de FreeRTOS a Zephyr usando K_MSGQ_DEFINE y K_THREAD_DEFINE. Presta atencion a la inversion de la convencion de prioridades. Compara el tamaño del binario resultante y el tiempo de compilacion.

  7. Grafo de ROS 2. Instala ROS 2 en una maquina o contenedor, ejecuta el nodo vigilante_bateria.py de este capitulo y comprueba con ros2 topic echo que publica. Despues cambia la fiabilidad del publicador a RELIABLE y la del suscriptor a BEST_EFFORT, y verifica con ros2 topic info --verbose que la comunicacion se rompe.

  8. Puente Elixir-ROS. Levanta el GenServer de puente UDP de este capitulo y escribe del otro lado un nodo minimo de ROS 2 en Python que reciba el JSON por UDP y lo republique en /cmd_vel. Comprueba que PuenteRos.Enlace.mover(0.2, 0.0) aparece en ros2 topic echo /cmd_vel.

  9. Reparto de responsabilidades. Elige un robot que quieras construir y escribe, en una tabla de tres columnas, que corre en tiempo real duro sobre el RTOS, que corre en el BEAM, y que corre en ROS 2 sobre el SBC. Justifica cada asignacion con la restriccion temporal correspondiente.

Que viene despues

En este capitulo cerraste el circulo de lo fisico y de lo estructural: ya sabes con que herramientas se diseña una placa y una carcasa, que archivos entrega uno a cada proveedor, como se calcula el costo real de comprar los componentes, que garantiza y que no garantiza un sistema operativo de tiempo real, y como se organiza un robot completo con ROS 2 cuando la logica supera lo que cabe en un microcontrolador. Tambien quedo claro donde encaja el BEAM en esa arquitectura: no en el lazo de control de 20 kHz, sino en la capa de logica, supervision y comunicacion, que es la que crece y cambia todo el tiempo.

En el capitulo 13 volvemos al codigo y entramos de lleno en AtomVM: que es exactamente una maquina virtual del BEAM que cabe en un microcontrolador, que subconjunto de Erlang/Elixir soporta y cual no, como se instala la cadena de herramientas, como se graba la imagen de la VM y despues el .avm de la aplicacion, y como se depura cuando algo falla dentro de un chip que no tiene pantalla. Es el capitulo que convierte todo lo anterior en un entorno de trabajo funcionando sobre tu escritorio.

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