Cuatro proyectos con ESP32: de un botón por serial a una flota con GPS

Por: Artiko
elixirroboticaiotelectronicaesp32atomvmmqttgpsfreertos

Cuatro proyectos con ESP32: de un botón por serial a una flota con GPS

En el capítulo 17 resolvimos la última pregunta que quedaba abierta: de dónde sale la energía cuando el dispositivo vive lejos de un enchufe. Con eso el inventario del curso quedó completo. Sabemos leer un esquemático, dimensionar una resistencia, elegir entre un microcontrolador y un computador de placa única, hablar I2C, montar AtomVM sobre un ESP32, escribir NIFs y orquestar procesos BEAM en un chip de 520 kB de RAM. Lo que falta no es más teoría: es juntar las piezas.

Este capítulo es exactamente eso: cuatro proyectos ordenados por dificultad creciente que recorren el arco entero del curso. El primero conecta un botón a un computador; el último rastrea un vehículo por red celular y predice cuándo llegará a un paradero. Cada uno se construye completo, con circuito, firmware, servicio de respaldo cuando lo necesita, pruebas y criterios de documentación.

El arco de los cuatro proyectos

Los proyectos no son independientes: cada uno agrega exactamente una capa sobre el anterior, y esa progresión explica por qué el orden importa.

ProyectoCapa nuevaTransporteLenguaje del firmware
1. Botón bidireccionalProtocolo de texto entre dos máquinasUSB serialC++ (Arduino core)
2. Alarma de proximidadRed, telemetría, persistencia y visualizaciónWiFi + MQTTC++ (Arduino core)
3. Robot de batallaPotencia, actuadores y servidor embebidoWiFi + HTTPElixir sobre AtomVM
4. Seguimiento GPSMovilidad fuera de WiFi, tareas concurrentes, backend geoespacialLTE + HTTP + SMSC/C++ con FreeRTOS

Hay una decisión de lenguaje deliberada. Los proyectos 1, 2 y 4 se escriben en C/C++ porque necesitan control fino del temporizado o periféricos que aún no están expuestos desde AtomVM. El 3 se escribe en Elixir porque su problema central —mantener vivo un servidor mientras el hardware se mueve y la conexión se cae— es el que el modelo de procesos de la BEAM resuelve directamente.

Material y presupuesto

Los precios son referenciales en pesos chilenos y sirven como orden de magnitud, no como cotización; los repuestos por errores de montaje no están contemplados.

ÍtemUsado enCantidadPrecio (CLP)
ESP32 DevKit V11, 2, 32 a 36.000 c/u
Protoboard, cables Dupont, LED, resistencias y botones1, 21 set8.000
Sensor HC-SR04, buzzer pasivo y servomotor SG9021 c/u7.000
Driver L298N y motores DC con reductora32 y 418.000
Batería 7,4 V a 12 V, chasis, aguja y globos32 juegos9.000
LilyGO T-A7670 (ESP32 con módem LTE y GNSS)4155.000
SIM con datos prepago y pantalla OLED SSD130641 c/u9.000

Una advertencia que aplica a los cuatro: el ESP32 trabaja a 3,3 V en sus GPIO y no es tolerante a 5 V, así que cualquier señal de 5 V que entre a un pin sin adaptación puede dañar el chip de forma permanente.

Proyecto 1: comunicación bidireccional entre ESP32 y computador

Un botón físico conectado al ESP32. Al presionarlo, el computador recibe un mensaje por el puerto serial y ejecuta una acción —bajar el brillo de pantalla—. El computador responde con un comando que enciende un LED en la placa, cerrando el lazo de confirmación. Es el “hola mundo” de la comunicación entre dispositivo y anfitrión, y contiene en miniatura todos los problemas que reaparecen después: encuadre de mensajes, rebote mecánico, sincronización y desconexión.

sequenceDiagram
    participant U as Usuario
    participant E as ESP32
    participant S as Serial 115200
    participant P as Puente en el PC
    participant D as Pantalla

    U->>E: presiona el botón (GPIO 4 cae a LOW)
    Note over E: espera 50 ms y<br/>vuelve a leer el pin
    E->>S: "BTN_PRESS\n"
    S->>P: línea completa
    P->>D: bajar brillo 10 %
    P->>S: "LED_ON\n"
    S->>E: línea completa
    E->>E: digitalWrite(LED, HIGH)
    Note over E: apaga tras 1 s o<br/>al recibir LED_OFF

Circuito

El botón va entre GPIO 4 y GND, sin resistencia externa porque se declara como INPUT_PULLUP. El LED va del GPIO 2 a GND en serie con una resistencia de 330 Ω; ese pin además tiene un LED integrado en muchas placas, así que sirve como respaldo si el montaje falla. Todo se alimenta desde el mismo cable USB que se usa para grabar.

El botón usa la resistencia de pull-up interna: el pin se lee HIGH en reposo y cae a LOW al presionar. La lógica queda invertida respecto de la intuición, pero ahorra un componente y deja el circuito inmune al ruido cuando nadie lo toca.

Firmware del ESP32

El archivo src/main.cpp, compilado con PlatformIO y el core Arduino-ESP32 3.x, queda así:

const int PIN_BOTON = 4;
const int PIN_LED   = 2;
const unsigned long DEBOUNCE_MS     = 50;
const unsigned long APAGADO_AUTO_MS = 1000;
int estadoEstableBoton = HIGH;   // HIGH = suelto, por el pull-up
int ultimaLecturaCruda = HIGH;
unsigned long tsUltimoCambio = 0;
bool ledEncendido = false;
unsigned long tsEncendidoLed = 0;
String bufferEntrada = "";
void aplicarComando(const String &cmd) {
  if (cmd == "LED_ON") {
    digitalWrite(PIN_LED, HIGH);
    ledEncendido = true;
    tsEncendidoLed = millis();
    Serial.println("ACK LED_ON");
  } else if (cmd == "LED_OFF") {
    digitalWrite(PIN_LED, LOW);
    ledEncendido = false;
    Serial.println("ACK LED_OFF");
  } else if (cmd == "PING") { Serial.println("PONG"); }
  else if (cmd.length() > 0) { Serial.println("ERR comando desconocido"); }
}
void leerSerial() {
  while (Serial.available() > 0) {
    char c = (char)Serial.read();
    if (c == '\n') {
      bufferEntrada.trim();
      aplicarComando(bufferEntrada);
      bufferEntrada = "";
    } else if (c != '\r') {
      if (bufferEntrada.length() < 64) bufferEntrada += c;
      else bufferEntrada = "";            // descarta lineas anomalas
    }
  }
}
void leerBoton() {
  int lectura = digitalRead(PIN_BOTON);
  if (lectura != ultimaLecturaCruda) {
    tsUltimoCambio = millis();
    ultimaLecturaCruda = lectura;
  }
  if ((millis() - tsUltimoCambio) > DEBOUNCE_MS && lectura != estadoEstableBoton) {
    estadoEstableBoton = lectura;
    if (estadoEstableBoton == LOW) Serial.println("BTN_PRESS");
  }
}
void setup() {
  Serial.begin(115200);
  pinMode(PIN_BOTON, INPUT_PULLUP);
  pinMode(PIN_LED, OUTPUT); digitalWrite(PIN_LED, LOW);
  delay(300);
  Serial.println("READY proyecto1 v1");
}
void loop() {
  leerBoton();
  leerSerial();
  if (ledEncendido && (millis() - tsEncendidoLed) > APAGADO_AUTO_MS) {
    digitalWrite(PIN_LED, LOW);
    ledEncendido = false;
  }
}

Tres decisiones merecen explicación. El antirrebote se hace por tiempo: cada cambio en la lectura cruda reinicia un cronómetro y el estado solo se acepta si sobrevive 50 ms sin variar; un botón mecánico produce decenas de transiciones en los primeros milisegundos y sin este filtro una pulsación generaría una ráfaga de mensajes. El bucle nunca llama a delay(), porque bloquear un segundo esperando apagar el LED haría perder pulsaciones y comandos. Y el protocolo es de una línea terminada en \n, sin longitudes ni binario, lo que permite depurar todo con un monitor serial y el teclado.

El platformio.ini declara platform = espressif32, board = esp32dev, framework = arduino y monitor_speed = 115200; sin esa última línea el monitor abre a 9600 y todo se ve como basura.

El puente en el computador

El programa del anfitrión abre el puerto, lee líneas y actúa. En la BEAM, Circuits.UART entrega encuadre por líneas, así que el GenServer recibe mensajes completos y no hay que manejar buffers a mano. En lib/puente/serial.ex, con {:circuits_uart, "~> 1.5"} en las dependencias:

defmodule Puente.Serial do
  @moduledoc "Puente entre el ESP32 del proyecto 1 y el escritorio."
  use GenServer
  require Logger
  alias Circuits.UART

  def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  @impl true
  def init(opts) do
    puerto = Keyword.get(opts, :puerto, "ttyUSB0")
    {:ok, pid} = UART.start_link()
    framing = {UART.Framing.Line, separator: "\n"}
    :ok = UART.open(pid, puerto, speed: 115_200, active: true, framing: framing)
    Logger.info("puerto #{puerto} abierto")
    {:ok, %{uart: pid, pulsaciones: 0}}
  end
  @impl true
  def handle_info({:circuits_uart, _p, {:error, motivo}}, estado),
    do: {:stop, {:uart, motivo}, estado}

  def handle_info({:circuits_uart, _p, linea}, estado),
    do: {:noreply, procesar(String.trim(linea), estado)}

  defp procesar("BTN_PRESS", estado) do
    n = estado.pulsaciones + 1
    case Puente.Brillo.bajar(10) do
      {:ok, nivel} ->
        Logger.info("pulsacion #{n}, brillo -> #{nivel}%")
        UART.write(estado.uart, "LED_ON")
      {:error, motivo} ->
        Logger.warning("sin control de brillo: #{motivo}")
        UART.write(estado.uart, "LED_OFF")
    end
    %{estado | pulsaciones: n}
  end

  defp procesar("READY" <> _ = msg, estado) do
    Logger.info("el dispositivo arranco: #{msg}")
    UART.write(estado.uart, "PING")
    estado
  end

  defp procesar(otro, estado), do: (Logger.debug("esp32: #{otro}"); estado)
end

El módulo Puente.Brillo es el único trozo dependiente del sistema operativo: en Linux con el retroiluminado expuesto en sysfs, bajar/1 lee max_brightness y brightness bajo /sys/class/backlight/<dispositivo>/, resta el porcentaje pedido y escribe el nuevo valor, devolviendo {:ok, porcentaje} o {:error, motivo} si falta el dispositivo o los permisos. Un detalle que muerde en cualquier lenguaje: la mayoría de las placas ESP32 se reinician cuando el anfitrión abre el puerto, porque las líneas DTR y RTS están cableadas a EN y GPIO0 para permitir la grabación automática. Por eso el puente no envía nada hasta recibir la línea READY. La ventaja adicional de la versión Elixir es que si el puerto desaparece, el GenServer muere y su supervisor lo reinicia, reabriendo el dispositivo cuando reaparece: el mismo argumento que sostiene el proyecto 3.

Cómo probarlo

Graba el firmware y abre el monitor serial: debe aparecer READY proyecto1 v1. Escribe LED_ON y presiona Enter, y el LED se enciende con un ACK LED_ON de respuesta. Cierra el monitor —solo un programa puede tener el puerto abierto a la vez— y ejecuta el puente. Al presionar el botón debe verse una única línea BTN_PRESS; si ves tres o cuatro, sube DEBOUNCE_MS a 80. Por último, desconecta el cable con el puente corriendo y vuelve a conectarlo para observar cómo se comporta tu programa ante la desaparición del dispositivo. Ese patrón —un botón físico que dispara una acción de software con confirmación luminosa— aparece en pulsadores de emergencia, botoneras de conteo de producción, interfaces de accesibilidad con activadores grandes y dispositivos de una sola función que reemplazan un atajo de teclado.

Proyecto 2: alarma de proximidad con MQTT, Home Assistant y Grafana

Un ESP32 mide distancia con un HC-SR04. Cuando algo entra dentro del umbral, la placa hace sonar un buzzer por sí sola, sin consultar a nadie. En paralelo publica cada lectura por MQTT; Home Assistant descubre la entidad automáticamente, la guarda en una base de datos y Grafana la grafica. En sentido inverso, un control deslizante en Home Assistant mueve un servomotor. La separación entre lo local y lo remoto es el corazón del proyecto: la seguridad no puede depender del WiFi, pero la observabilidad sí puede.

flowchart TD
    subgraph campo["En el dispositivo"]
        HC["HC-SR04<br/>trigger 10 us, eco con pulseIn"]
        MED["Mediana de 5 muestras"]
        LOG["Umbral 30 cm + histéresis 5 cm"]
        BUZ["Buzzer PWM 2 kHz"]
        SRV["Servo SG90 PWM 50 Hz"]
        HC --> MED --> LOG --> BUZ
    end
    subgraph red["En la red local"]
        MOS["Mosquitto :1883"]
        HA["Home Assistant<br/>MQTT discovery"]
        DB[("MariaDB<br/>tabla states")]
        GRA["Grafana<br/>panel Time Series"]
    end
    MED -->|"publish casa/alarma/distancia"| MOS
    MOS --> HA --> DB --> GRA
    HA -->|"publish casa/alarma/servo/set"| MOS
    MOS -->|"subscribe"| SRV

Circuito y adaptación de niveles

SeñalPin ESP32ConexiónNota
HC-SR04 VCCVIN (5 V)DirectoEl sensor necesita 5 V para todo su rango
HC-SR04 TRIGGPIO 5DirectoEl sensor reconoce 3,3 V como nivel alto
HC-SR04 ECHOGPIO 18Vía divisor 1 kΩ / 2 kΩEl eco sale a 5 V y hay que bajarlo
Buzzer (+)GPIO 25Directo, o con transistor sobre 20 mABuzzer pasivo, no activo
Servo SG90GPIO 13 para la señal, 5 V externo para VCCFuente aparte del USBPWM de 50 Hz; puede pedir picos de 700 mA
TierrasGNDTodas unidasSin tierra común el servo no responde

Con 1 kΩ entre ECHO y el nodo, y 2 kΩ entre el nodo y GND, la tensión resultante es 5 V × 2000 / 3000 = 3,33 V, justo dentro del rango del ESP32. El buzzer debe ser pasivo: uno activo trae su propio oscilador y solo distingue encendido de apagado, mientras que uno pasivo necesita que le entreguemos la frecuencia.

Firmware

El archivo src/main.cpp usa las librerías PubSubClient y ESP32Servo:

#include <WiFi.h>
#include <PubSubClient.h>
#include <ESP32Servo.h>
const char *WIFI_SSID = "TU_RED", *WIFI_PASS = "TU_CLAVE";
const char *MQTT_HOST = "192.168.1.50";
const char *MQTT_USER = "esp32", *MQTT_PASS = "clave_mqtt";
const char *T_DISTANCIA = "casa/alarma/distancia";
const char *T_ESTADO    = "casa/alarma/estado";
const char *T_SERVO_SET = "casa/alarma/servo/set";
const char *T_SERVO_EST = "casa/alarma/servo/estado";
const char *T_DISC_SENSOR = "homeassistant/sensor/alarma_distancia/config";
const char *T_DISC_SERVO  = "homeassistant/number/alarma_servo/config";
const int PIN_TRIG = 5, PIN_ECHO = 18, PIN_PARLANTE = 25, PIN_SERVO = 13;
const float UMBRAL_CM = 30.0f, HISTERESIS_CM = 5.0f;
const int MUESTRAS = 5;
WiFiClient wifi;
PubSubClient mqtt(wifi);
Servo servo;
bool alarmaActiva = false;
unsigned long tsMedicion = 0, tsPublicacion = 0;
float ultimaDistancia = -1.0f;
float medirDistanciaCm() {
  digitalWrite(PIN_TRIG, LOW);  delayMicroseconds(2);
  digitalWrite(PIN_TRIG, HIGH); delayMicroseconds(10);
  digitalWrite(PIN_TRIG, LOW);
  unsigned long duracion = pulseIn(PIN_ECHO, HIGH, 30000UL);
  if (duracion == 0) return -1.0f;          // sin eco dentro de 30 ms
  return (duracion * 0.0343f) / 2.0f;       // 343 m/s, ida y vuelta
}
float medianaDeCinco() {
  float v[MUESTRAS];
  int n = 0;
  for (int i = 0; i < MUESTRAS; i++) {
    float d = medirDistanciaCm();
    if (d > 1.0f && d < 400.0f) v[n++] = d;
    delay(20);                              // el HC-SR04 pide separacion
  }
  if (n == 0) return -1.0f;
  for (int i = 1; i < n; i++)               // insercion, n muy pequeno
    for (int j = i; j > 0 && v[j - 1] > v[j]; j--) {
      float x = v[j]; v[j] = v[j - 1]; v[j - 1] = x;
    }
  return v[n / 2];
}
void evaluarAlarma(float d) {
  if (d < 0) return;
  if (!alarmaActiva && d < UMBRAL_CM) {
    alarmaActiva = true;
    ledcWriteTone(PIN_PARLANTE, 2000);
    mqtt.publish(T_ESTADO, "ON", true);
  } else if (alarmaActiva && d > UMBRAL_CM + HISTERESIS_CM) {
    alarmaActiva = false;
    ledcWriteTone(PIN_PARLANTE, 0);
    mqtt.publish(T_ESTADO, "OFF", true);
  }
}
void alRecibir(char *topic, byte *payload, unsigned int largo) {
  char texto[16] = {0}, eco[8];
  memcpy(texto, payload, largo < 15 ? largo : 15);
  if (strcmp(topic, T_SERVO_SET) != 0) return;
  int angulo = constrain(atoi(texto), 0, 180);
  servo.write(angulo);
  snprintf(eco, sizeof(eco), "%d", angulo);
  mqtt.publish(T_SERVO_EST, eco, true);
}
void publicarDescubrimiento() {
  mqtt.publish(T_DISC_SENSOR,
    "{\"name\":\"Distancia alarma\",\"state_topic\":\"casa/alarma/distancia\","
    "\"unit_of_measurement\":\"cm\",\"device_class\":\"distance\","
    "\"state_class\":\"measurement\",\"unique_id\":\"alarma_distancia_01\","
    "\"device\":{\"identifiers\":[\"alarma01\"],\"name\":\"Alarma\"}}", true);
  mqtt.publish(T_DISC_SERVO,
    "{\"name\":\"Servo alarma\",\"command_topic\":\"casa/alarma/servo/set\","
    "\"state_topic\":\"casa/alarma/servo/estado\",\"min\":0,\"max\":180,\"step\":1,"
    "\"unique_id\":\"alarma_servo_01\",\"device\":{\"identifiers\":[\"alarma01\"]}}", true);
}
void reconectarMqtt() {
  if (mqtt.connected() || WiFi.status() != WL_CONNECTED) return;
  // los ultimos argumentos son el testamento: topic, qos, retain, mensaje
  if (mqtt.connect("alarma_proximidad_01", MQTT_USER, MQTT_PASS,
                   T_ESTADO, 0, true, "UNAVAILABLE")) {
    mqtt.subscribe(T_SERVO_SET);
    publicarDescubrimiento();
  }
}
void conectarWifi() {
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  unsigned long inicio = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - inicio < 20000) delay(300);
  Serial.println(WiFi.status() == WL_CONNECTED
                 ? WiFi.localIP().toString()
                 : "sin WiFi: la alarma local sigue operativa");
}
void setup() {
  Serial.begin(115200);
  pinMode(PIN_TRIG, OUTPUT); pinMode(PIN_ECHO, INPUT);
  ledcAttach(PIN_PARLANTE, 2000, 10);   // pin, frecuencia base, resolucion
  ledcWriteTone(PIN_PARLANTE, 0);
  servo.setPeriodHertz(50); servo.attach(PIN_SERVO, 500, 2400); servo.write(90);
  conectarWifi();
  mqtt.setServer(MQTT_HOST, 1883);
  mqtt.setCallback(alRecibir);
  mqtt.setBufferSize(512);              // los JSON de discovery no caben en 256
}
void loop() {
  if (WiFi.status() != WL_CONNECTED) conectarWifi();
  reconectarMqtt();
  mqtt.loop();
  unsigned long ahora = millis();
  if (ahora - tsMedicion >= 200) {
    tsMedicion = ahora;
    evaluarAlarma(ultimaDistancia = medianaDeCinco());
  }
  if (ahora - tsPublicacion >= 1000 && ultimaDistancia > 0 && mqtt.connected()) {
    tsPublicacion = ahora;
    char payload[16];
    snprintf(payload, sizeof(payload), "%.1f", ultimaDistancia);
    mqtt.publish(T_DISTANCIA, payload);
  }
}

Nota sobre versiones: en Arduino-ESP32 3.x el periférico LEDC se configura con ledcAttach(pin, frecuencia, resolucion) y se opera con ledcWriteTone(pin, frecuencia). En la serie 2.x la API era por canal, con ledcSetup, ledcAttachPin y ledcWriteTone(canal, frecuencia). Si copias código antiguo y no compila, esta suele ser la causa. El testamento MQTT publica UNAVAILABLE en el topic de estado si el broker pierde contacto con la placa; sin él, Home Assistant seguiría mostrando el último valor conocido como si fuera actual.

Infraestructura con Docker Compose

services:
  mosquitto:
    image: eclipse-mosquitto:2
    restart: unless-stopped
    ports: ["1883:1883"]
    volumes: ["./mosquitto/config:/mosquitto/config", "./mosquitto/data:/mosquitto/data"]
  mariadb:
    image: mariadb:11
    restart: unless-stopped
    environment: {MARIADB_ROOT_PASSWORD: raiz, MARIADB_DATABASE: homeassistant,
                  MARIADB_USER: hass, MARIADB_PASSWORD: clave_hass}
    volumes: ["./mariadb:/var/lib/mysql"]
  homeassistant:
    image: ghcr.io/home-assistant/home-assistant:stable
    restart: unless-stopped
    network_mode: host
    volumes: ["./hass-config:/config", "/etc/localtime:/etc/localtime:ro"]
    depends_on: [mosquitto, mariadb]
  grafana:
    image: grafana/grafana-oss:latest
    restart: unless-stopped
    ports: ["3000:3000"]

El archivo mosquitto/config/mosquitto.conf cierra el acceso anónimo con listener 1883 0.0.0.0, allow_anonymous false y password_file /mosquitto/config/passwd. Las credenciales se generan una vez con docker compose run --rm mosquitto mosquitto_passwd -c -b /mosquitto/config/passwd esp32 clave_mqtt, y la publicación se verifica sin tocar Home Assistant con:

docker compose exec mosquitto \
  mosquitto_sub -h localhost -u esp32 -P clave_mqtt -t 'casa/alarma/#' -v

En hass-config/configuration.yaml se apunta el registrador a MariaDB con un bloque recorder: que define db_url: mysql://hass:[email protected]/homeassistant?charset=utf8mb4, un purge_keep_days: 30 para que la base no crezca sin límite y un include: con las dos entidades del proyecto, de modo que no se registre todo lo demás.

La integración MQTT se agrega desde la interfaz apuntando al broker. Una vez conectada, los mensajes retenidos en homeassistant/.../config crean las entidades sin escribir YAML adicional: ese es el mecanismo de descubrimiento automático.

El panel de Grafana

Grafana no habla con Home Assistant: habla con la base que Home Assistant escribe. En esquemas recientes los valores viven en states y el nombre de la entidad está normalizado en states_meta, por lo que hace falta un JOIN:

SELECT
  s.last_updated_ts * 1000       AS time_msec,
  CAST(s.state AS DECIMAL(10,2)) AS distancia_cm
FROM states s
JOIN states_meta m ON s.metadata_id = m.metadata_id
WHERE m.entity_id = 'sensor.distancia_alarma'
  AND s.state NOT IN ('unknown', 'unavailable', '')
  AND s.last_updated_ts BETWEEN $__unixEpochFrom() AND $__unixEpochTo()
ORDER BY s.last_updated_ts;

Conviene que el usuario con el que Grafana se conecta solo pueda leer, creándolo con GRANT SELECT ON homeassistant.* y sin permisos de escritura.

Decisiones y por qué

MQTT se prefiere sobre un POST directo a una API porque el canal queda abierto en ambos sentidos y no hace falta sondear para recibir el ángulo del servo. La alarma se evalúa en la placa y no como automatización de Home Assistant para que el buzzer suene aunque el router esté apagado y la latencia no dependa de la red. La mediana de cinco muestras descarta reflexiones espurias sin arrastrar el error a las muestras siguientes, cosa que un promedio móvil sí hace. La histéresis de cinco centímetros evita que un objeto detenido justo en el límite haga oscilar el buzzer varias veces por segundo. Y MariaDB se usa en lugar de InfluxDB o Prometheus porque reutiliza la base que Home Assistant ya necesita, evitando un servicio más.

Proyecto 3: robots de batalla con Elixir sobre AtomVM

Dos robots. Cada uno lleva un ESP32 corriendo AtomVM, un driver L298N, dos motores con reductora, una aguja al frente y un globo atrás. Ambos se conectan a la misma red y cada uno levanta un servidor HTTP en el puerto 80. El piloto abre la IP del robot en el navegador del teléfono y controla el movimiento desde una página que el propio robot sirve. Gana quien revienta el globo del otro. Es el primer proyecto con potencia real: motores que tiran corriente, batería aparte y un puente H que puede dañarse si se le pide un cambio de dirección instantáneo.

El puente H, en concreto

Un motor DC gira en un sentido u otro según la polaridad que reciba. Un puente H son cuatro interruptores que permiten invertir esa polaridad sin tocar los cables. El L298N trae dos puentes, uno por motor, con dos pines de dirección y uno de habilitación por canal.

stateDiagram-v2
    [*] --> Detenido
    Detenido --> Adelante: IN1=1 IN2=0 IN3=1 IN4=0
    Detenido --> Atras: IN1=0 IN2=1 IN3=0 IN4=1
    Detenido --> GiroIzq: IN1=0 IN2=1 IN3=1 IN4=0
    Detenido --> GiroDer: IN1=1 IN2=0 IN3=0 IN4=1
    Adelante --> Detenido: comando stop o<br/>watchdog 400 ms
    Atras --> Detenido: comando stop o<br/>watchdog 400 ms
    GiroIzq --> Detenido: idem
    GiroDer --> Detenido: idem
    note right of Detenido
        Nunca se pasa de Adelante a Atras
        directamente: siempre se cruza por
        Detenido con una pausa de 60 ms
    end note

Esa nota no es estética: invertir la dirección mientras el motor gira genera un pico de corriente que puede activar la protección térmica del L298N o dañarlo. El cableado entre placa y driver queda así:

SeñalGPIO ESP32Pin L298NFunción
IN1 e IN213 y 14IN1, IN2Sentidos A y B del motor izquierdo
IN3 e IN427 y 26IN3, IN4Sentidos A y B del motor derecho
ENA / ENB12 y 33ENA, ENBPWM de velocidad por motor
Batería12 V7,4 V a 12 V desde LiPo o portapilas
Tierra comúnGNDGNDBatería, L298N y ESP32 comparten GND

Con el jumper de 5 V puesto y una batería que no supere los 12 V, el módulo entrega 5 V regulados que sirven para alimentar el ESP32 por VIN; por sobre 12 V ese regulador se sobrecalienta y conviene quitar el jumper y usar una fuente separada.

Control de motores

En lib/robot/motores.ex:

defmodule Robot.Motores do
  @moduledoc """
  Control de dos motores DC via L298N. Mantiene el estado en un proceso y
  garantiza el paso por :detenido antes de invertir el sentido.
  """
  use GenServer

  @in1 13
  @in2 14
  @in3 27
  @in4 26
  @pausa_inversion_ms 60
  @ventana_watchdog_ms 400

  def start_link(_opts), do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
  def latir, do: GenServer.cast(__MODULE__, :latido)
  def adelante, do: GenServer.cast(__MODULE__, {:mover, :adelante})
  def atras, do: GenServer.cast(__MODULE__, {:mover, :atras})
  def izquierda, do: GenServer.cast(__MODULE__, {:mover, :izquierda})
  def derecha, do: GenServer.cast(__MODULE__, {:mover, :derecha})
  def detener, do: GenServer.cast(__MODULE__, {:mover, :detenido})
  @impl true
  def init(:ok) do
    for pin <- [@in1, @in2, @in3, @in4] do
      :gpio.set_pin_mode(pin, :output)
      :gpio.digital_write(pin, :low)
    end
    :timer.send_interval(100, :revisar)
    {:ok, %{actual: :detenido, ultimo_latido: ahora_ms()}}
  end
  @impl true
  def handle_cast(:latido, estado), do: {:noreply, %{estado | ultimo_latido: ahora_ms()}}
  def handle_cast({:mover, igual}, %{actual: igual} = estado), do: {:noreply, estado}

  def handle_cast({:mover, nuevo}, %{actual: actual} = estado) do
    if inversion?(actual, nuevo) do
      escribir(:detenido)
      :timer.sleep(@pausa_inversion_ms)
    end
    escribir(nuevo)
    {:noreply, %{estado | actual: nuevo, ultimo_latido: ahora_ms()}}
  end
  # watchdog: sin comandos dentro de la ventana, los motores se apagan
  @impl true
  def handle_info(:revisar, %{actual: :detenido} = estado), do: {:noreply, estado}

  def handle_info(:revisar, estado) do
    if ahora_ms() - estado.ultimo_latido > @ventana_watchdog_ms do
      escribir(:detenido)
      {:noreply, %{estado | actual: :detenido}}
    else
      {:noreply, estado}
    end
  end

  defp ahora_ms, do: :erlang.system_time(:millisecond)

  defp inversion?(:adelante, :atras), do: true
  defp inversion?(:atras, :adelante), do: true
  defp inversion?(:izquierda, :derecha), do: true
  defp inversion?(:derecha, :izquierda), do: true
  defp inversion?(_, _), do: false
  # {IN1, IN2, IN3, IN4}
  defp patron(:adelante), do: {:high, :low, :high, :low}
  defp patron(:atras), do: {:low, :high, :low, :high}
  defp patron(:izquierda), do: {:low, :high, :high, :low}
  defp patron(:derecha), do: {:high, :low, :low, :high}
  defp patron(:detenido), do: {:low, :low, :low, :low}

  defp escribir(movimiento) do
    {a, b, c, d} = patron(movimiento)
    :gpio.digital_write(@in1, a)
    :gpio.digital_write(@in2, b)
    :gpio.digital_write(@in3, c)
    :gpio.digital_write(@in4, d)
  end
end

El watchdog vive dentro del mismo proceso que gobierna los pines: un temporizador dispara :revisar cada 100 ms y, si pasaron más de 400 ms sin comandos ni latidos, escribe el patrón de detención. Es la red de seguridad ante la pérdida de WiFi o el cierre del navegador.

Servidor HTTP embebido

AtomVM expone :gen_tcp, así que un servidor HTTP mínimo cabe en un proceso; en lib/robot/servidor.ex:

defmodule Robot.Servidor do
  @moduledoc "Sirve la pagina de control y atiende GET /mover?d=<direccion>."

  @puerto 80

  def start_link(_opts), do: {:ok, spawn_link(fn -> escuchar() end)}

  def child_spec(opts),
    do: %{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}, type: :worker}

  defp escuchar do
    opciones = [{:active, false}, {:packet, :raw}, {:reuseaddr, true}, {:backlog, 4}]
    {:ok, escucha} = :gen_tcp.listen(@puerto, opciones)
    :io.format(~c"servidor en el puerto ~p~n", [@puerto])
    aceptar(escucha)
  end

  defp aceptar(escucha) do
    case :gen_tcp.accept(escucha) do
      {:ok, socket} -> atender(socket) && :gen_tcp.close(socket)
      {:error, motivo} -> :io.format(~c"accept fallo: ~p~n", [motivo])
    end
    aceptar(escucha)
  end

  defp atender(socket) do
    case :gen_tcp.recv(socket, 0, 3000) do
      {:ok, datos} -> datos |> to_string() |> primera_linea() |> enrutar() |> enviar(socket)
      {:error, _} -> :ok
    end
    true
  end

  defp primera_linea(peticion) do
    case :binary.split(peticion, "\r\n") do
      [linea | _] -> linea
      _ -> peticion
    end
  end

  defp enrutar("GET /mover?d=" <> resto) do
    direccion = resto |> :binary.split(" ") |> hd()
    aplicar(direccion)
    Robot.Motores.latir()
    {200, "text/plain", direccion}
  end

  defp enrutar("GET / " <> _), do: {200, "text/html", pagina()}
  defp enrutar(_), do: {404, "text/plain", "no existe"}
  defp aplicar("adelante"), do: Robot.Motores.adelante()
  defp aplicar("atras"), do: Robot.Motores.atras()
  defp aplicar("izquierda"), do: Robot.Motores.izquierda()
  defp aplicar("derecha"), do: Robot.Motores.derecha()
  defp aplicar(_), do: Robot.Motores.detener()

  defp enviar({codigo, tipo, cuerpo}, socket) do
    :gen_tcp.send(socket, [
      "HTTP/1.1 ", Integer.to_string(codigo), " OK\r\n",
      "Content-Type: ", tipo, "\r\n",
      "Content-Length: ", Integer.to_string(byte_size(cuerpo)), "\r\n",
      "Connection: close\r\n\r\n",
      cuerpo
    ])
  end

  defp pagina do
    """
    <!doctype html><html lang="es"><head><meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>Robot</title><style>
    body{margin:0;background:#111;color:#eee;font-family:system-ui,sans-serif;
    display:grid;place-items:center;height:100vh;touch-action:none}
    .m{display:grid;grid-template-columns:repeat(3,5rem);gap:.6rem}
    button{height:5rem;font-size:1.6rem;border:0;border-radius:.6rem;
    background:#2d3748;color:#eee}.x{visibility:hidden}
    </style></head><body><div class="m">
    <button class="x"></button><button data-d="adelante">A</button>
    <button class="x"></button><button data-d="izquierda">I</button>
    <button data-d="detenido">S</button><button data-d="derecha">D</button>
    <button class="x"></button><button data-d="atras">R</button>
    <button class="x"></button></div>
    <script>
    let activo=null;
    const enviar=d=>fetch('/mover?d='+d).catch(()=>{});
    document.querySelectorAll('button[data-d]').forEach(b=>{
      const d=b.dataset.d;
      b.addEventListener('pointerdown',e=>{e.preventDefault();activo=d;enviar(d);});
      ['pointerup','pointercancel','pointerleave'].forEach(ev=>
        b.addEventListener(ev,e=>{e.preventDefault();activo=null;enviar('detenido');}));
    });
    setInterval(()=>{if(activo)enviar(activo);},200);
    </script></body></html>
    """
  end
end

El punto de entrada, en lib/robot.ex, conecta el WiFi y arma el árbol de supervisión:

defmodule Robot do
  @config [
    sta: [
      ssid: "RED_ARENA",
      psk: "clave_arena",
      got_ip: fn info -> :io.format(~c"ip: ~p~n", [info]) end,
      disconnected: fn -> :io.format(~c"wifi caido~n") end
    ]
  ]

  def start do
    {:ok, _ip} = :network.wait_for_sta(@config, 30_000)
    hijos = [Robot.Motores, Robot.Servidor]
    {:ok, _} = :supervisor.start_link({:local, :robot_sup}, __MODULE__, hijos)
    dormir()
  end
  # callback de :supervisor
  def init(hijos) do
    specs = for m <- hijos, do: %{id: m, start: {m, :start_link, [[]]}, restart: :permanent}
    {:ok, {%{strategy: :one_for_one, intensity: 10, period: 5}, specs}}
  end

  defp dormir do
    :timer.sleep(60_000)
    dormir()
  end
end

En mix.exs se declara el punto de entrada para ExAtomVM con atomvm: [start: Robot, flash_offset: 0x250000] y la dependencia {:exatomvm, github: "atomvm/exatomvm", runtime: false}. El flujo de grabación es el del capítulo 13: mix atomvm.packbeam produce el .avm y mix atomvm.esp32.flash lo escribe en la partición correspondiente.

Por qué Elixir aquí

El robot tiene tres actividades que compiten: atender peticiones HTTP, escribir en los GPIO y vigilar que no se haya perdido el contacto. En C esto se resuelve con máquinas de estado dentro de un bucle, o con tareas de FreeRTOS y semáforos. En AtomVM son tres procesos, cada uno con su bucle y su estado aislado. Si el proceso del servidor muere porque un navegador mandó una petición malformada, el supervisor lo reinicia y los motores no se enteran: siguen en el estado que tenían, y el watchdog los detendrá si nadie vuelve a hablar en 400 ms. Un robot cuyos motores quedan encendidos cuando la conexión se corta es un robot que se estrella contra la pared; el watchdog convierte un fallo de red en una detención.

Sobre las reglas de combate: una arena de dos metros por lado con borde de cinco centímetros, combates de tres minutos o hasta que un globo reviente, aguja fija de máximo tres centímetros sin filos laterales, globo inflado a quince centímetros montado atrás y a la vista, ambos robots con IP fija sobre un punto de acceso común y treinta segundos de margen para reconectar si uno pierde la red. Antes de encender los motores por primera vez, levanta el chasis para que las ruedas queden en el aire y prueba todos los comandos: un error de cableado que invierte un motor se detecta en tres segundos así.

Proyecto 4: seguimiento GPS con estimación de tiempo de llegada

El proyecto más grande del curso y el único que sale de la casa. Un módulo viaja a bordo de un vehículo: lee su posición por GNSS, la envía por red celular a un servidor y responde mensajes SMS con su última ubicación conocida. Un segundo módulo, instalado en un paradero, consulta ese servidor y muestra cuántos minutos faltan. Un backend guarda el historial y calcula distancias sobre la superficie terrestre.

sequenceDiagram
    autonumber
    participant G as Módulo GNSS<br/>(A7670 en el bus)
    participant S as Backend<br/>Phoenix + PostGIS
    participant P as Módulo paradero
    participant U as Pasajero

    loop cada 5 s
        G->>G: AT+CGNSSINFO, parsea lat y lon
    end
    loop cada 10 s
        G->>S: POST /api/gps por LTE {bus_id, lat, lon}
        S->>S: INSERT en tabla gps
        S-->>G: 201 Created
    end
    loop cada 10 s
        P->>S: GET /api/eta?lat=..&lon=..
        S->>S: ST_Distance y velocidad por ventana LAG
        S-->>P: {distancia_m, eta_min}
        P->>U: "Bus 1 a 4 min"
    end
    U->>G: SMS "ubicacion"
    G->>U: SMS con enlace de mapa

Hardware y arquitectura de tareas

La placa recomendada es una LilyGO T-A7670, que integra un ESP32-WROVER-E y un módem SIMCom A7670 con LTE Cat-1 y receptor GNSS. Evita cablear un módulo celular y un GPS por separado, y resuelve la alimentación del módem, que es donde fallan la mayoría de los montajes caseros: un módem puede pedir picos de 2 A durante el registro en la red, muy por encima de lo que entrega un puerto USB.

En esa placa el pin de encendido del módem (PWRKEY) está en GPIO 4, el UART hacia el módem usa GPIO 26 para transmitir y GPIO 27 para recibir, y la tensión de la batería se mide por el ADC de GPIO 35.

Una regla que no admite excepciones: nunca alimentes el módem sin la antena LTE conectada, porque un transmisor de radio sin carga refleja toda la potencia hacia su etapa de salida y la destruye. Para el módulo del paradero basta un ESP32 DevKit con una pantalla OLED SSD1306 o un LCD 1602, ambos por I2C en GPIO 21 y 22. Tres actividades no pueden esperarse entre sí: leer GNSS, mandar HTTP y atender SMS. Y las tres hablan por el mismo UART hacia el módem. Ese es exactamente el problema que resuelve un sistema operativo de tiempo real.

flowchart TD
    subgraph nucleo1["Núcleo 1"]
        TG["Tarea GNSS<br/>prioridad 2, cada 5 s"]
        TS["Tarea SMS<br/>prioridad 1, escucha"]
    end
    subgraph nucleo0["Núcleo 0"]
        TH["Tarea HTTP<br/>prioridad 2, cada 10 s"]
    end
    MUTEX{{"mutexUart<br/>xSemaphoreTake"}}
    UART["UART1 hacia el módem A7670"]
    DATOS[("lat, lon, fijo<br/>protegidos por mutexDatos")]
    TG --> MUTEX
    TH --> MUTEX
    TS --> MUTEX
    MUTEX --> UART
    TG -->|escribe| DATOS
    DATOS -->|copia local| TH

Sin el mutex ocurre lo siguiente: la tarea GNSS envía AT+CGNSSINFO y, antes de que llegue la respuesta, la tarea HTTP envía AT+HTTPACTION=1. El módem responde ambas cosas por el mismo cable y cada tarea lee la respuesta de la otra, con lo que el parser ve basura y las coordenadas caen en medio del océano. El firmware, en src/main.cpp, queda así:

#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/semphr.h>
#define MODEM_TX 26
#define MODEM_RX 27
#define MODEM_PWRKEY 4
const char *APN = "bam.entelpcs.cl";
const char *ID_BUS = "bus_1";
const char *URL_GPS = "https://tu-backend.example.com/api/gps";
const char *TOKEN = "reemplaza_por_tu_token";
HardwareSerial modem(1);
SemaphoreHandle_t mutexUart, mutexDatos;
struct Posicion { double lat; double lon; bool fijo; };
Posicion posicion = {0.0, 0.0, false};
String enviarAT(const char *comando, unsigned long timeoutMs) {
  while (modem.available()) modem.read();          // limpia residuos
  modem.println(comando);
  String r = "";
  unsigned long inicio = millis();
  while (millis() - inicio < timeoutMs) {
    while (modem.available()) r += (char)modem.read();
    if (r.indexOf("OK") >= 0 || r.indexOf("ERROR") >= 0) break;
    vTaskDelay(pdMS_TO_TICKS(10));
  }
  return r;
}
String atConMutex(const char *comando, unsigned long timeoutMs) {
  String r = "";
  if (xSemaphoreTake(mutexUart, pdMS_TO_TICKS(8000)) == pdTRUE) {
    r = enviarAT(comando, timeoutMs);
    xSemaphoreGive(mutexUart);
  }
  return r;
}
void encenderModem() {                             // pulso largo en PWRKEY
  pinMode(MODEM_PWRKEY, OUTPUT);
  digitalWrite(MODEM_PWRKEY, LOW);  delay(100);
  digitalWrite(MODEM_PWRKEY, HIGH); delay(1000);
  digitalWrite(MODEM_PWRKEY, LOW);  delay(5000);   // tarda en responder al arrancar
}
// El A7670 devuelve grados decimales, no el formato DDMM.MMMM de NMEA.
// Campos: modo,sat_gps,sat_glo,sat_bds,lat,N/S,lon,E/W,fecha,hora,...
bool parsearCgnssinfo(const String &linea, double &lat, double &lon) {
  int inicio = linea.indexOf("+CGNSSINFO:");
  if (inicio < 0) return false;
  String cuerpo = linea.substring(inicio + 11);
  cuerpo.trim();
  if (cuerpo.startsWith(",,")) return false;       // sin fijo todavia
  String campo[10];
  int n = 0, desde = 0, coma;
  while (n < 10 && (coma = cuerpo.indexOf(',', desde)) >= 0) {
    campo[n++] = cuerpo.substring(desde, coma);
    desde = coma + 1;
  }
  if (n < 10) campo[n++] = cuerpo.substring(desde);
  if (n < 8 || campo[4].length() == 0 || campo[6].length() == 0) return false;
  lat = campo[4].toDouble(); if (campo[5] == "S") lat = -lat;
  lon = campo[6].toDouble(); if (campo[7] == "W") lon = -lon;
  if (lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0) return false;
  return !(lat == 0.0 && lon == 0.0);
}
Posicion leerCopia() {
  Posicion c = {0.0, 0.0, false};
  if (xSemaphoreTake(mutexDatos, pdMS_TO_TICKS(500)) == pdTRUE) {
    c = posicion;
    xSemaphoreGive(mutexDatos);
  }
  return c;
}
void tareaGnss(void *args) {
  atConMutex("AT+CGNSSPWR=1,1", 10000);            // enciende el receptor
  vTaskDelay(pdMS_TO_TICKS(3000));
  for (;;) {
    double lat = 0.0, lon = 0.0;
    if (parsearCgnssinfo(atConMutex("AT+CGNSSINFO", 5000), lat, lon)) {
      if (xSemaphoreTake(mutexDatos, pdMS_TO_TICKS(500)) == pdTRUE) {
        posicion = {lat, lon, true};
        xSemaphoreGive(mutexDatos);
      }
      Serial.printf("[gnss] %.6f, %.6f\n", lat, lon);
    } else {
      Serial.println("[gnss] sin fijo");
    }
    vTaskDelay(pdMS_TO_TICKS(5000));
  }
}
void tareaHttp(void *args) {
  char cmd[256];
  snprintf(cmd, sizeof(cmd), "AT+CGDCONT=1,\"IP\",\"%s\"", APN);
  atConMutex(cmd, 5000);
  atConMutex("AT+CGACT=1,1", 20000);
  for (;;) {
    Posicion c = leerCopia();
    if (c.fijo && xSemaphoreTake(mutexUart, pdMS_TO_TICKS(15000)) == pdTRUE) {
      char cuerpo[160];
      snprintf(cuerpo, sizeof(cuerpo),
               "{\"bus_id\":\"%s\",\"lat\":%.6f,\"lon\":%.6f}", ID_BUS, c.lat, c.lon);
      enviarAT("AT+HTTPTERM", 2000);               // cierra sesion previa
      enviarAT("AT+HTTPINIT", 5000);
      snprintf(cmd, sizeof(cmd), "AT+HTTPPARA=\"URL\",\"%s\"", URL_GPS);
      enviarAT(cmd, 5000);
      enviarAT("AT+HTTPPARA=\"CONTENT\",\"application/json\"", 5000);
      snprintf(cmd, sizeof(cmd), "AT+HTTPPARA=\"USERDATA\",\"Authorization: Bearer %s\"", TOKEN);
      enviarAT(cmd, 5000);
      snprintf(cmd, sizeof(cmd), "AT+HTTPDATA=%d,10000", (int)strlen(cuerpo));
      enviarAT(cmd, 3000);
      modem.print(cuerpo);
      vTaskDelay(pdMS_TO_TICKS(1500));
      Serial.println(enviarAT("AT+HTTPACTION=1", 25000));
      enviarAT("AT+HTTPTERM", 3000);
      xSemaphoreGive(mutexUart);
    }
    vTaskDelay(pdMS_TO_TICKS(10000));
  }
}
void responderSms(const String &numero) {
  Posicion c = leerCopia();
  char texto[180];
  if (c.fijo)
    snprintf(texto, sizeof(texto), "GPS: %.5f,%.5f https://maps.google.com/?q=%.5f,%.5f",
             c.lat, c.lon, c.lat, c.lon);
  else snprintf(texto, sizeof(texto), "GPS: sin fijo aun");
  if (xSemaphoreTake(mutexUart, pdMS_TO_TICKS(10000)) == pdTRUE) {
    char cmd[64];
    enviarAT("AT+CMGF=1", 3000);
    snprintf(cmd, sizeof(cmd), "AT+CMGS=\"%s\"", numero.c_str());
    modem.println(cmd);
    vTaskDelay(pdMS_TO_TICKS(600));
    modem.print(texto);
    modem.write(26);                               // Ctrl+Z termina el mensaje
    vTaskDelay(pdMS_TO_TICKS(6000));
    xSemaphoreGive(mutexUart);
  }
}
void tareaSms(void *args) {
  atConMutex("AT+CMGF=1", 3000);                   // modo texto
  atConMutex("AT+CNMI=2,2,0,0,0", 3000);           // entrega directa por UART
  String buffer = "";
  for (;;) {
    if (xSemaphoreTake(mutexUart, pdMS_TO_TICKS(300)) == pdTRUE) {
      while (modem.available()) buffer += (char)modem.read();
      xSemaphoreGive(mutexUart);
    }
    int marca = buffer.indexOf("+CMT:");
    if (marca >= 0) {
      int c1 = buffer.indexOf('"', marca), c2 = buffer.indexOf('"', c1 + 1);
      String numero = buffer.substring(c1 + 1, c2);
      String cuerpo = buffer.substring(buffer.indexOf('\n', c2) + 1);
      cuerpo.trim(); cuerpo.toLowerCase();
      if (cuerpo.indexOf("ubicacion") >= 0 || cuerpo.indexOf("gps") >= 0) responderSms(numero);
      buffer = "";
    }
    if (buffer.length() > 512) buffer = "";
    vTaskDelay(pdMS_TO_TICKS(500));
  }
}
void setup() {
  Serial.begin(115200);
  delay(500);
  mutexUart = xSemaphoreCreateMutex(); mutexDatos = xSemaphoreCreateMutex();
  modem.begin(115200, SERIAL_8N1, MODEM_RX, MODEM_TX);
  encenderModem();
  atConMutex("AT", 3000);
  atConMutex("ATE0", 3000);                        // sin eco: parser mas simple
  // en la respuesta de CREG, 0,1 indica red local y 0,5 roaming
  Serial.println(atConMutex("AT+CREG?", 3000));
  xTaskCreatePinnedToCore(tareaGnss, "gnss", 4096, NULL, 2, NULL, 1);
  xTaskCreatePinnedToCore(tareaHttp, "http", 8192, NULL, 2, NULL, 0);
  xTaskCreatePinnedToCore(tareaSms,  "sms",  4096, NULL, 1, NULL, 1);
}
void loop() { vTaskDelay(pdMS_TO_TICKS(10000)); }

Los tamaños de pila no son arbitrarios: la tarea HTTP maneja cadenas más largas y por eso lleva 8 kB, mientras que las otras dos operan con buffers pequeños y 4 kB alcanzan. Una pila insuficiente produce un reinicio con el mensaje stack canary watchpoint triggered, una de las trazas más frecuentes en este proyecto.

El backend con Phoenix y PostGIS

Con PostGIS los cálculos geodésicos son una función de base de datos y no código propio. El esquema:

CREATE EXTENSION IF NOT EXISTS postgis;
CREATE TABLE gps (
  id          bigserial PRIMARY KEY,
  bus_id      text             NOT NULL,
  lat         double precision NOT NULL,
  lon         double precision NOT NULL,
  geom        geography(Point, 4326)
              GENERATED ALWAYS AS (ST_MakePoint(lon, lat)::geography) STORED,
  inserted_at timestamptz      NOT NULL DEFAULT now()
);
CREATE INDEX gps_bus_ts_idx ON gps (bus_id, inserted_at DESC);
CREATE INDEX gps_geom_idx   ON gps USING GIST (geom);

La columna geom es generada, así que PostgreSQL la calcula sola en cada inserción, y el tipo geography hace que las funciones de distancia trabajen sobre el elipsoide terrestre en metros en lugar de sobre un plano cartesiano en grados. El esquema Ecto Rastreo.Posiciones.Punto mapea esa tabla con los campos bus_id, lat, lon e inserted_at, y valida en su changeset que la latitud caiga entre -90 y 90 y la longitud entre -180 y 180. Sobre él, el contexto en lib/rastreo/posiciones.ex reúne la distancia al paradero y la velocidad media reciente:

defmodule Rastreo.Posiciones do
  @moduledoc "Registro de posiciones y estimacion de llegada."
  import Ecto.Query
  alias Rastreo.{Repo, Posiciones.Punto}

  @velocidad_respaldo_kmh 20.0
  @umbral_llegada_m 50.0

  @sql_distancia """
  SELECT ST_Distance(ST_MakePoint($1, $2)::geography,
                     ST_MakePoint($3, $4)::geography)
  """

  @sql_velocidad """
  WITH ordenado AS (
    SELECT geom, inserted_at,
           LAG(geom)        OVER (ORDER BY inserted_at) AS geom_prev,
           LAG(inserted_at) OVER (ORDER BY inserted_at) AS ts_prev
    FROM gps
    WHERE bus_id = $1 AND inserted_at > now() - interval '5 minutes'
  ),
  tramos AS (
    SELECT ST_Distance(geom, geom_prev) AS metros,
           EXTRACT(EPOCH FROM (inserted_at - ts_prev)) AS segundos
    FROM ordenado WHERE geom_prev IS NOT NULL
  )
  SELECT COALESCE(SUM(metros) / NULLIF(SUM(segundos), 0) * 3.6, $2)
  FROM tramos
  WHERE segundos BETWEEN 1 AND 120
  """

  def registrar(attrs), do: %Punto{} |> Punto.changeset(attrs) |> Repo.insert()

  def ultima(bus_id) do
    Punto
    |> where([p], p.bus_id == ^bus_id)
    |> order_by([p], desc: p.inserted_at)
    |> limit(1)
    |> Repo.one()
  end

  @doc "Distancia en metros y tiempo estimado en minutos hasta el punto dado."
  def eta(bus_id, lat, lon) do
    case ultima(bus_id) do
      nil ->
        {:error, :sin_posicion}
      punto ->
        distancia = consultar(@sql_distancia, [punto.lon, punto.lat, lon, lat])
        velocidad = consultar(@sql_velocidad, [bus_id, @velocidad_respaldo_kmh])
        minutos =
          if distancia <= @umbral_llegada_m,
            do: 0.0,
            else: Float.round(distancia / (velocidad * 1000.0 / 60.0), 1)
        {:ok,
         %{
           bus_id: bus_id, lat: punto.lat, lon: punto.lon, eta_min: minutos,
           distancia_m: Float.round(distancia, 1),
           velocidad_kmh: Float.round(velocidad, 1),
           medido_en: punto.inserted_at
         }}
    end
  end

  defp consultar(sql, parametros) do
    %{rows: [[valor]]} = Repo.query!(sql, parametros)
    valor
  end
end

El COALESCE a 20 km/h da un valor de respaldo cuando no hay historial, y el filtro segundos BETWEEN 1 AND 120 descarta tramos sin cobertura: sin él, un salto de veinte minutos entre dos puntos produciría una velocidad falsa cercana a cero. El controlador expone las tres rutas de la API:

defmodule RastreoWeb.GpsController do
  use RastreoWeb, :controller
  alias Rastreo.Posiciones

  def status(conn, _params), do: json(conn, %{estado: "ok", ts: DateTime.utc_now()})

  def create(conn, %{"bus_id" => _, "lat" => _, "lon" => _} = params) do
    case Posiciones.registrar(params) do
      {:ok, punto} -> conn |> put_status(:created) |> json(%{id: punto.id})
      {:error, cs} -> conn |> put_status(422) |> json(%{errores: inspect(cs.errors)})
    end
  end

  def create(conn, _), do: conn |> put_status(400) |> json(%{error: "faltan campos"})

  def eta(conn, %{"lat" => lat, "lon" => lon} = params) do
    bus_id = Map.get(params, "bus_id", "bus_1")
    with {lat_f, _} <- Float.parse(lat),
         {lon_f, _} <- Float.parse(lon),
         {:ok, datos} <- Posiciones.eta(bus_id, lat_f, lon_f) do
      json(conn, datos)
    else
      :error -> conn |> put_status(400) |> json(%{error: "coordenadas invalidas"})
      {:error, :sin_posicion} -> conn |> put_status(404) |> json(%{error: "sin datos"})
    end
  end
end

Son tres: GET /api/status responde 200 con una marca de tiempo y la consulta el módulo móvil para su reporte por SMS; POST /api/gps responde 201 con el id insertado y la llama el módulo móvil cada diez segundos; GET /api/eta?lat=&lon=&bus_id= responde 200 con distancia, velocidad y minutos, y la consulta el módulo del paradero al mismo ritmo.

Firmware del módulo del paradero

En src/paradero.cpp, con Adafruit_GFX, Adafruit_SSD1306 y ArduinoJson:

#include <WiFi.h>
#include <HTTPClient.h>
#include <Wire.h>
#include <ArduinoJson.h>
#include <Adafruit_SSD1306.h>
const char *WIFI_SSID = "TU_RED", *WIFI_PASS = "TU_CLAVE";
const double PARADERO_LAT = -33.045123;   // tomadas de un mapa
const double PARADERO_LON = -71.612345;
const char *BASE_URL = "https://tu-backend.example.com";
Adafruit_SSD1306 pantalla(128, 64, &Wire, -1);
unsigned long tsConsulta = 0;
void mostrar(const char *l1, const char *l2, const char *l3) {
  pantalla.clearDisplay();
  pantalla.setTextColor(SSD1306_WHITE);
  pantalla.setTextSize(1); pantalla.setCursor(0, 0);  pantalla.println(l1);
  pantalla.setTextSize(2); pantalla.setCursor(0, 20); pantalla.println(l2);
  pantalla.setTextSize(1); pantalla.setCursor(0, 52); pantalla.println(l3);
  pantalla.display();
}
void consultar() {
  if (WiFi.status() != WL_CONNECTED) {
    mostrar("Paradero", "Sin red", "reintentando...");
    WiFi.reconnect();
    return;
  }
  char url[200];
  snprintf(url, sizeof(url), "%s/api/eta?lat=%.6f&lon=%.6f&bus_id=bus_1",
           BASE_URL, PARADERO_LAT, PARADERO_LON);
  HTTPClient http;
  http.begin(url);
  http.setTimeout(8000);
  int codigo = http.GET();
  if (codigo == 200) {
    JsonDocument doc;
    if (deserializeJson(doc, http.getString()) == DeserializationError::Ok) {
      double eta = doc["eta_min"] | -1.0;
      double distancia = doc["distancia_m"] | -1.0;
      char l2[24], l3[32];
      if (eta <= 0.0) snprintf(l2, sizeof(l2), "Llegando");
      else snprintf(l2, sizeof(l2), "%.0f min", eta);
      snprintf(l3, sizeof(l3), "a %.0f m", distancia);
      mostrar("Bus 1", l2, l3);
    } else {
      mostrar("Bus 1", "Error", "json invalido");
    }
  } else if (codigo == 404) {
    mostrar("Bus 1", "Sin datos", "fuera de servicio");
  } else {
    char err[24];
    snprintf(err, sizeof(err), "HTTP %d", codigo);
    mostrar("Bus 1", "Error", err);
  }
  http.end();
}
void setup() {
  Serial.begin(115200);
  Wire.begin(21, 22);
  if (!pantalla.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
    Serial.println("no se encontro la pantalla en 0x3C");
    for (;;) delay(1000);
  }
  mostrar("Paradero", "Iniciando", "");
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  unsigned long inicio = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - inicio < 20000) delay(300);
}
void loop() {
  if (millis() - tsConsulta >= 10000) { tsConsulta = millis(); consultar(); }
  delay(50);
}

Sobre credenciales: en el código de este capítulo las claves aparecen como constantes de ejemplo, pero en un despliegue real conviene no versionar el archivo que las contiene, usar tokens con permisos limitados a insertar filas en una tabla, y rotarlos si el dispositivo se pierde.

Comparación final de los cuatro proyectos

DimensiónProyecto 1Proyecto 2Proyecto 3Proyecto 4
TransporteUART sobre USBWiFi + MQTTWiFi + HTTPLTE + HTTP + SMS
Latencia típicamenos de 5 ms50 a 200 ms30 a 150 ms1 a 5 s
Alcance y consumo1 m de cable, 40 mAla red local, 80 mAla red local, 90 mA sin motorescobertura celular, picos de 2 A
Sin conexiónirrelevantela alarma suena iguallos motores se detienensigue leyendo GNSS
Concurrenciabucle no bloqueantebucle no bloqueanteprocesos BEAM supervisadostareas FreeRTOS con mutex
Almacenamiento y costoninguno, 12.000 CLPMariaDB vía Home Assistant, 28.000 CLPninguno, 26.000 CLP por dos unidadesPostgreSQL con PostGIS, 65.000 CLP
Riesgo eléctrico principalningunoECHO a 5 V en un GPIOcorriente de motores y tierras separadasmódem sin antena y picos de corriente

Errores comunes y cómo salir de ellos

SíntomaCausaSolución
Caracteres ilegibles en el monitor serialVelocidad distinta entre firmware y monitorAjustar ambos a 115200 y revisar monitor_speed en platformio.ini
Una pulsación genera cinco mensajesRebote mecánico sin filtrarSubir DEBOUNCE_MS a 80 y aplicar el filtro al flanco, no al nivel
El puente no recibe nada al arrancarLa placa se reinicia al abrir el puertoEsperar la línea READY antes de escribir y limpiar el buffer de entrada
El HC-SR04 devuelve siempre -1, o salta entre 3 y 200 cmEl eco no llega, el divisor está mal armado o hay reflexionesMedir 3,3 V en el nodo del divisor, revisar el timeout de pulseIn y usar la mediana de cinco muestras
La entidad no aparece en Home Assistant, o queda no disponibleEl JSON de descubrimiento excede el buffer de PubSubClient, o el testamento MQTT se disparóLlamar a mqtt.setBufferSize(512) antes de conectar, publicar con retención y republicar el estado tras reconectar
Grafana devuelve cero filasEl esquema reciente separa entity_id en states_metaUsar el JOIN entre states y states_meta
Un motor gira al revésPolaridad invertida en la salida del L298NIntercambiar los cables de ese motor o invertir el patrón en patron/1
El ESP32 se reinicia al arrancar, o el L298N se calienta y cortaFuente compartida con los motores, o inversión de sentido sin pasar por detenidoAlimentar motores con batería separada uniendo solo las tierras, y mantener la transición a :detenido con la pausa de 60 ms
El robot sigue avanzando al cerrar el navegadorEl watchdog no recibe latidosConfirmar que la página envía el comando cada 200 ms y que handle_info(:revisar, …) se ejecuta
AT no responde nadaEl módem no encendió, el UART está cruzado o falta corrienteRepetir el pulso en PWRKEY, verificar TX contra RX y usar una fuente capaz de dar 2 A
Coordenadas cercanas a 0,0, o respuestas AT mezcladas entre tareasSe parseó una respuesta sin fijo GNSS, o falta el mutex sobre el UARTDescartar campos vacíos y validar rango, y envolver cada secuencia comando-respuesta entre xSemaphoreTake y xSemaphoreGive
Reinicio con stack canary watchpoint triggered, o +HTTPACTION con error de redPila insuficiente en una tarea, o contexto de datos sin activarSubir la pila en xTaskCreatePinnedToCore a 8192 en la tarea HTTP, y ejecutar AT+CGDCONT y AT+CGACT=1,1 antes del primer POST
El ETA salta a valores absurdosUn tramo con hueco de cobertura ensució la velocidadFiltrar tramos por duración razonable y usar el valor de respaldo con COALESCE
La pantalla OLED queda en blancoDirección I2C distinta a 0x3CEscanear el bus y probar con 0x3D

Cómo documentar el trabajo

Los cuatro proyectos comparten la misma exigencia de entrega, que conviene tratar como parte del diseño y no como un trámite posterior.

  1. Lista de materiales con costos reales, no la teórica: lo que compraste, dónde y a qué precio, incluyendo los repuestos que necesitaste por errores.
  2. Esquemático de conexiones donde cada pin esté nombrado y cada tierra sea trazable. KiCad o Fritzing sirven; un dibujo a mano fotografiado también, si es legible.
  3. Código versionado y comentado en un repositorio con historial. Los comentarios explican decisiones, no repiten lo que el código ya dice.
  4. Evidencia de funcionamiento: fotografías del montaje y un video corto donde se vea el sistema operando de punta a punta, incluyendo el caso de falla.
  5. Registro de fallas y aplicaciones industriales: qué se rompió, por qué y cómo lo resolviste, más tres escenarios donde la misma arquitectura resuelva un problema real.

Ejercicios propuestos

  1. Protocolo con verificación. Extiende el protocolo del proyecto 1 para que cada mensaje lleve número de secuencia y suma de verificación. Modifica ambos extremos para descartar mensajes corruptos y contar cuántos se perdieron en una hora de operación.
  2. Puente supervisado. Agrega a Puente.Serial un supervisor con estrategia :one_for_one que lo reinicie cuando el puerto desaparece. Desconecta el cable durante la ejecución y verifica que se reconecte solo al volver a enchufarlo.
  3. Segundo sensor. Agrega al proyecto 2 un DHT22 en un GPIO libre. Publica temperatura y humedad en topics nuevos con su propio descubrimiento, y crea un panel de Grafana que superponga distancia y temperatura en el mismo eje temporal.
  4. Umbral remoto. Convierte UMBRAL_CM en una entidad de tipo number configurable desde Home Assistant. Guárdalo en NVS para que sobreviva a un corte de energía y verifica que la alarma local siga funcionando con el valor guardado aunque el WiFi no vuelva.
  5. Velocidad variable. Usa el módulo :ledc de AtomVM para gobernar ENA y ENB con PWM. Agrega dos botones a la interfaz web que ajusten la velocidad entre 40 % y 100 %, y mide cuánto cambia la distancia de frenado.
  6. Telemetría del robot. Agrega un endpoint /estado que responda con memoria libre, tiempo desde el arranque y último movimiento aplicado, y muéstralo en la página de control refrescándolo cada segundo.
  7. Tolerancia a desconexión. Modifica el módulo móvil del proyecto 4 para que, cuando el POST falle, guarde la posición en una cola circular en memoria y la reenvíe al volver la red. Define cuántas posiciones caben antes de descartar las más antiguas y justifica el número.
  8. ETA por historial. Reemplaza la estimación por velocidad instantánea con una que use el tiempo mediano en que el vehículo recorrió ese mismo tramo durante los últimos siete días. Compara ambas predicciones contra el tiempo real por una semana y cuantifica la diferencia.
  9. Mapa en vivo y portabilidad. Construye una página que muestre la posición del vehículo sobre un mapa y se actualice sola con Phoenix LiveView, y luego reescribe la tarea de envío HTTP del módulo móvil como un proceso de AtomVM dejando el GNSS en C mediante un NIF. Documenta qué partes de esa traducción fueron directas y cuáles requirieron trabajo adicional.

Cierre del curso

Aquí termina el recorrido. Empezamos en el capítulo 1 con la historia de las máquinas que se mueven solas. Pasamos por la ley de Ohm, por el divisor de tensión que hoy usaste para proteger un GPIO, por el temporizador 555 y por los microcontroladores que lo reemplazaron. Vimos por qué una Raspberry Pi y un ESP32 resuelven problemas distintos, cómo hablan I2C dos chips en la misma placa, qué hace un sistema operativo de tiempo real y por qué la BEAM —una máquina virtual diseñada para centrales telefónicas— resultó funcionar dentro de un microcontrolador de treinta dólares.

Los cuatro proyectos de este capítulo son la prueba de que esas piezas encajan. En el primero, un botón y un LED. En el último, un vehículo que reporta su posición por red celular mientras tres tareas se turnan un mismo cable serial y un backend calcula distancias sobre la superficie de la Tierra. La distancia entre ambos extremos es de acumulación: cada capa se apoya en la anterior y ninguna es misteriosa una vez que se abre. Lo que sigue depende de ti. Puedes profundizar en Nerves si el problema pide Linux y una imagen completa, en GRiSP si necesitas la BEAM sobre metal desnudo, o quedarte en AtomVM y contribuir a un proyecto joven con mucho territorio sin cubrir. También puedes tomar cualquiera de estos cuatro proyectos y llevarlo más allá: mejor precisión, menor consumo, más dispositivos, una interfaz que alguien más quiera usar.

El índice completo del curso queda disponible para volver a cualquier capítulo cuando lo necesites: la teoría de circuitos del capítulo 3 cuando dudes de una conexión, el capítulo 13 cuando quieras grabar otra vez el VM, o el capítulo 17 cuando el proyecto tenga que sobrevivir una noche entera sin sol.