Cuatro proyectos con ESP32: de un botón por serial a una flota con GPS
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.
| Proyecto | Capa nueva | Transporte | Lenguaje del firmware |
|---|---|---|---|
| 1. Botón bidireccional | Protocolo de texto entre dos máquinas | USB serial | C++ (Arduino core) |
| 2. Alarma de proximidad | Red, telemetría, persistencia y visualización | WiFi + MQTT | C++ (Arduino core) |
| 3. Robot de batalla | Potencia, actuadores y servidor embebido | WiFi + HTTP | Elixir sobre AtomVM |
| 4. Seguimiento GPS | Movilidad fuera de WiFi, tareas concurrentes, backend geoespacial | LTE + HTTP + SMS | C/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.
| Ítem | Usado en | Cantidad | Precio (CLP) |
|---|---|---|---|
| ESP32 DevKit V1 | 1, 2, 3 | 2 a 3 | 6.000 c/u |
| Protoboard, cables Dupont, LED, resistencias y botones | 1, 2 | 1 set | 8.000 |
| Sensor HC-SR04, buzzer pasivo y servomotor SG90 | 2 | 1 c/u | 7.000 |
| Driver L298N y motores DC con reductora | 3 | 2 y 4 | 18.000 |
| Batería 7,4 V a 12 V, chasis, aguja y globos | 3 | 2 juegos | 9.000 |
| LilyGO T-A7670 (ESP32 con módem LTE y GNSS) | 4 | 1 | 55.000 |
| SIM con datos prepago y pantalla OLED SSD1306 | 4 | 1 c/u | 9.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ñal | Pin ESP32 | Conexión | Nota |
|---|---|---|---|
| HC-SR04 VCC | VIN (5 V) | Directo | El sensor necesita 5 V para todo su rango |
| HC-SR04 TRIG | GPIO 5 | Directo | El sensor reconoce 3,3 V como nivel alto |
| HC-SR04 ECHO | GPIO 18 | Vía divisor 1 kΩ / 2 kΩ | El eco sale a 5 V y hay que bajarlo |
| Buzzer (+) | GPIO 25 | Directo, o con transistor sobre 20 mA | Buzzer pasivo, no activo |
| Servo SG90 | GPIO 13 para la señal, 5 V externo para VCC | Fuente aparte del USB | PWM de 50 Hz; puede pedir picos de 700 mA |
| Tierras | GND | Todas unidas | Sin 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ñal | GPIO ESP32 | Pin L298N | Función |
|---|---|---|---|
| IN1 e IN2 | 13 y 14 | IN1, IN2 | Sentidos A y B del motor izquierdo |
| IN3 e IN4 | 27 y 26 | IN3, IN4 | Sentidos A y B del motor derecho |
| ENA / ENB | 12 y 33 | ENA, ENB | PWM de velocidad por motor |
| Batería | — | 12 V | 7,4 V a 12 V desde LiPo o portapilas |
| Tierra común | GND | GND | Baterí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ón | Proyecto 1 | Proyecto 2 | Proyecto 3 | Proyecto 4 |
|---|---|---|---|---|
| Transporte | UART sobre USB | WiFi + MQTT | WiFi + HTTP | LTE + HTTP + SMS |
| Latencia típica | menos de 5 ms | 50 a 200 ms | 30 a 150 ms | 1 a 5 s |
| Alcance y consumo | 1 m de cable, 40 mA | la red local, 80 mA | la red local, 90 mA sin motores | cobertura celular, picos de 2 A |
| Sin conexión | irrelevante | la alarma suena igual | los motores se detienen | sigue leyendo GNSS |
| Concurrencia | bucle no bloqueante | bucle no bloqueante | procesos BEAM supervisados | tareas FreeRTOS con mutex |
| Almacenamiento y costo | ninguno, 12.000 CLP | MariaDB vía Home Assistant, 28.000 CLP | ninguno, 26.000 CLP por dos unidades | PostgreSQL con PostGIS, 65.000 CLP |
| Riesgo eléctrico principal | ninguno | ECHO a 5 V en un GPIO | corriente de motores y tierras separadas | módem sin antena y picos de corriente |
Errores comunes y cómo salir de ellos
| Síntoma | Causa | Solución |
|---|---|---|
| Caracteres ilegibles en el monitor serial | Velocidad distinta entre firmware y monitor | Ajustar ambos a 115200 y revisar monitor_speed en platformio.ini |
| Una pulsación genera cinco mensajes | Rebote mecánico sin filtrar | Subir DEBOUNCE_MS a 80 y aplicar el filtro al flanco, no al nivel |
| El puente no recibe nada al arrancar | La placa se reinicia al abrir el puerto | Esperar 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 cm | El eco no llega, el divisor está mal armado o hay reflexiones | Medir 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 disponible | El 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 filas | El esquema reciente separa entity_id en states_meta | Usar el JOIN entre states y states_meta |
| Un motor gira al revés | Polaridad invertida en la salida del L298N | Intercambiar 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 corta | Fuente compartida con los motores, o inversión de sentido sin pasar por detenido | Alimentar 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 navegador | El watchdog no recibe latidos | Confirmar que la página envía el comando cada 200 ms y que handle_info(:revisar, …) se ejecuta |
AT no responde nada | El módem no encendió, el UART está cruzado o falta corriente | Repetir 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 tareas | Se parseó una respuesta sin fijo GNSS, o falta el mutex sobre el UART | Descartar 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 red | Pila insuficiente en una tarea, o contexto de datos sin activar | Subir 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 absurdos | Un tramo con hueco de cobertura ensució la velocidad | Filtrar tramos por duración razonable y usar el valor de respaldo con COALESCE |
| La pantalla OLED queda en blanco | Dirección I2C distinta a 0x3C | Escanear 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.
- 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.
- 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.
- Código versionado y comentado en un repositorio con historial. Los comentarios explican decisiones, no repiten lo que el código ya dice.
- 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.
- 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
- 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.
- Puente supervisado. Agrega a
Puente.Serialun supervisor con estrategia:one_for_oneque lo reinicie cuando el puerto desaparece. Desconecta el cable durante la ejecución y verifica que se reconecte solo al volver a enchufarlo. - 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.
- Umbral remoto. Convierte
UMBRAL_CMen una entidad de tiponumberconfigurable 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. - Velocidad variable. Usa el módulo
:ledcde 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. - Telemetría del robot. Agrega un endpoint
/estadoque 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. - 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.
- 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.
- 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.