IA
IA con ESP32: TinyML en la placa e integración con un LLM
Las tres maneras de usar IA con la ESP32 — un modelo pequeño en el chip, un LLM en un servidor o los dos juntos —, con ejemplos y cuidados de seguridad.
Por Prof. Rodrigo Faustino · Actualizado el · 12 min de lectura
Métricas de este artículo
- Palabras
- 2393
- Caracteres
- 13.721
- Tiempo de lectura
- ~12 min
"¿Se puede poner inteligencia artificial en una ESP32?" La respuesta corta es sí, pero no de la manera que mucha gente imagina. Un modelo de lenguaje como los que conversan en los chats no cabe en una placa de pocos dólares: los más pequeños ocupan cientos de megabytes, y la ESP32 de la DevKit V1 tiene 4 MB de flash y, en MicroPython, menos de 200 KB de memoria libre para el programa. Lo que cabe — y funciona muy bien — son modelos pequeños, entrenados para una sola pregunta: "¿esta vibración es normal?", "¿este sonido es un aplauso?", "¿esta lectura es de un ambiente bochornoso?".
Para preguntas abiertas, en texto libre, el camino es otro: la ESP32 conversa por la red con un modelo grande, que corre en un computador de la escuela, en un servidor o en la nube. Este artículo muestra los dos caminos y un tercero, que junta los dos. Los ejemplos con el botón Simular corren en el navegador; los de red son para llevar a la placa de verdad.
Tres caminos
- IA en la placa (lo que se llama TinyML, aprendizaje automático minúsculo). Los datos se recolectan con los sensores, el modelo se entrena en el computador y después se implanta en la ESP32, que pasa a decidir sola. Funciona sin internet, responde en milisegundos y no manda los datos a ningún lado. A cambio, solo responde a la pregunta para la que fue entrenado.
- IA en el servidor. La ESP32 lee los sensores y envía los datos a un servidor, que le pregunta a un LLM (large language model, el modelo de lenguaje grande) y devuelve una respuesta. El LLM entiende texto libre y sabe muchas cosas, pero necesita red, tarda segundos en responder, puede costar dinero por pedido y puede equivocarse con toda la confianza.
- Los dos juntos. El modelo pequeño, en la placa, resuelve los casos comunes al instante; solo cuando está inseguro la placa le pregunta al LLM. La mayor parte de las decisiones no sale de la placa, y la red se usa poco.
La IA que recuerda tu vida y vive en tu ordenador
- Memoria a largo plazo con la fuente de cada respuesta
- Funciona sin conexión: tus datos se quedan en tu PC
- Lee PDF, diapositivas, fotos y grabaciones de reuniones
Plan Esencial gratis para siempre
IA en la placa: recolectar, entrenar, implantar
Todo proyecto de TinyML sigue el mismo ciclo de tres pasos.
Recolectar. El modelo aprende con ejemplos etiquetados: lecturas de los sensores acompañadas de la respuesta correcta. Para reconocer un ambiente bochornoso, por ejemplo, se mide la temperatura y la humedad durante varios días y se anota, en cada lectura, cómo estaba el ambiente. Los datos pueden ir a un archivo CSV en la propia placa, como en el artículo guardar datos en la ESP32, y después al computador. Ese es el paso más trabajoso y el que más decide si el modelo va a funcionar: pocos ejemplos, o ejemplos de un solo día, dan un modelo que acierta en el laboratorio y falla en la vida real.
Entrenar. En el computador, un programa busca una manera de ir de las lecturas a la etiqueta. Para los modelos pequeños de los ejemplos de abajo, "entrenar" es solo guardar los ejemplos o calcular promedios. Para modelos mayores, se usa una biblioteca de aprendizaje automático en el computador, y el entrenamiento puede llevar minutos.
Implantar. El modelo entrenado se vuelve un archivo que va a la placa — a veces, una simple lista de números dentro del main.py. De ahí en adelante, la ESP32 lee el sensor, pasa la lectura por el modelo y actúa con la respuesta.
Primer modelo: los k vecinos más cercanos
El modelo más simple de entender es el de los k vecinos más cercanos (k-nearest neighbors, kNN). Guarda los ejemplos etiquetados y, para una lectura nueva, busca los k ejemplos más parecidos y responde con la etiqueta que más aparece entre ellos. "Más parecido" es la distancia entre las lecturas, la misma regla de la geometría.
El ejemplo usa diez lecturas de temperatura y humedad, como las de un DHT22, ya etiquetadas. Los números son ejemplos inventados para la clase, no medidas: en un proyecto, vienen del paso de recolección.
import math
# (temperatura en °C, humedad en %, etiqueta) — datos de ejemplo para la clase
exemplos = [
(18.0, 55, 'frío'), (17.5, 60, 'frío'), (19.0, 50, 'frío'),
(23.0, 50, 'agradable'), (24.5, 45, 'agradable'), (22.0, 55, 'agradable'),
(29.0, 80, 'bochornoso'), (31.0, 75, 'bochornoso'), (28.0, 85, 'bochornoso'),
(33.0, 40, 'caluroso seco'),
]
def distancia(a, b):
# la humedad varía en decenas y la temperatura en unidades: dividir por el rango
# deja a las dos con el mismo peso (eso se llama normalizar)
dt = (a[0] - b[0]) / 20
du = (a[1] - b[1]) / 50
return math.sqrt(dt * dt + du * du)
def classificar(leitura, k=3):
perto = sorted(exemplos, key=lambda e: distancia(leitura, e))[:k]
votos = {}
for e in perto:
votos[e[2]] = votos.get(e[2], 0) + 1
rotulo = max(votos, key=votos.get)
return rotulo, votos[rotulo], k
for leitura in [(30.0, 78), (21.0, 52), (18.5, 58), (26.0, 65)]:
rotulo, a_favor, k = classificar(leitura)
print('%.1f °C, %d %% -> %s (%d de %d vecinos)' % (leitura[0], leitura[1], rotulo, a_favor, k))Fíjate en la lectura de 26 °C y 65 %: queda entre los grupos, y los vecinos no concuerdan todos. El conteo de votos es una medida de confianza que el modelo da gratis — y es ella la que permite el tercer camino, más adelante.
El kNN tiene un costo: en cada lectura, compara con todos los ejemplos. Con diez, es instantáneo; con miles, la placa pasa a gastar tiempo y memoria. Por eso, en los proyectos mayores, entran modelos que resumen los ejemplos.
Entrenar e implantar con el potenciómetro
Un modelo que resume los ejemplos es el centroide más cercano: entrenar es calcular el promedio de cada grupo, y clasificar es ver de qué promedio está más cerca la lectura. El modelo implantado son tres números.
En el circuito, el potenciómetro hace el papel de un sensor cualquiera (una palanca, un sensor de luz, de humedad del suelo). El programa ya trae los ejemplos "recolectados" en tres posiciones, entrena, y después lee el pin de verdad y clasifica. Cuando la lectura está lejos de todos los centroides, el modelo dice que está inseguro — y, en el tercer camino, ese es el momento de preguntarle al servidor.
from machine import ADC, Pin
import time
# 1. recolectar: lecturas en mV con el eje en tres posiciones (ejemplos para la clase)
coletados = {
'bajo': [210, 260, 190, 240, 230],
'medio': [1580, 1660, 1700, 1620, 1640],
'alto': [2950, 3010, 2900, 3050, 2980],
}
# 2. entrenar: el modelo es el promedio de cada grupo
modelo = {}
for rotulo, valores in coletados.items():
modelo[rotulo] = sum(valores) / len(valores)
print('modelo entrenado:', {r: round(m) for r, m in modelo.items()})
# 3. implantar: leer el sensor y elegir el centroide más cercano
LIMITE_INSEGURO = 400 # mV: más lejos que eso de todos, el modelo no confía
adc = ADC(Pin(34), atten=ADC.ATTN_11DB)
def prever(mv):
rotulo = min(modelo, key=lambda r: abs(mv - modelo[r]))
return rotulo, abs(mv - modelo[rotulo])
for i in range(3):
mv = adc.read_uv() // 1000
rotulo, dist = prever(mv)
if dist > LIMITE_INSEGURO:
print('%d mV -> inseguro (más cerca de %s, a %d mV): le preguntaría al servidor' % (mv, rotulo, dist))
else:
print('%d mV -> %s' % (mv, rotulo))
time.sleep(1)Gira el potenciómetro en el simulador y corre de nuevo: cerca de las puntas, el modelo responde "bajo" o "alto"; entre los grupos, se declara inseguro. El LIMITE_INSEGURO es una elección del proyecto: mayor, el modelo arriesga más; menor, pregunta más veces.
Bibliotecas para modelos mayores
Cuando el problema pide más que promedios y vecinos — reconocer un movimiento en un acelerómetro, un sonido, un gesto —, el trabajo pesado del entrenamiento queda en el computador, con bibliotecas de aprendizaje automático, y la placa recibe solo el modelo listo. Para MicroPython, un proyecto abierto que hace eso es emlearn-micropython. Según la página del proyecto, ofrece clasificación con árboles de decisión y bosques aleatorios, kNN (incluso aprendiendo en la propia placa), redes neuronales convolucionales pequeñas, regresión lineal y agrupamiento con k-medias, además de filtros y FFT para preparar las señales. Los modelos corren como módulos nativos (.mpy) que se instalan sin regrabar el firmware; el proyecto dice estar probado principalmente en la arquitectura xtensawin, la de la ESP32 y de la ESP32-S3. Para la ESP32-C3, que es RISC-V, consulta la documentación del proyecto antes de planificar la clase.
La regla de oro del TinyML vale para cualquier biblioteca: el modelo es tan bueno como los datos con los que fue entrenado. Separa siempre una parte de los ejemplos para probar, sin usarlos en el entrenamiento, y cuenta cuántos acierta el modelo. Un modelo que acierta el 100 % de los ejemplos de entrenamiento y falla los de prueba memorizó la lista en lugar de aprender.
IA en el servidor: la ESP32 conversando con un LLM
El segundo camino cambia el modelo pequeño por un modelo de lenguaje, que entiende y escribe texto. La ESP32 entra como ojos y manos: mide, envía, recibe una respuesta y actúa. La conversación es por HTTP, el mismo requests del artículo Wi-Fi, HTTP y MQTT.
Hay dos maneras comunes de tener un LLM a disposición.
Un modelo en la red local, con Ollama. Ollama es un programa abierto que corre modelos de lenguaje en un computador común. Atiende pedidos HTTP en el puerto 11434; según la documentación, escucha solo en el propio computador (127.0.0.1) hasta que la variable de entorno OLLAMA_HOST le mande escuchar en la red, por ejemplo con 0.0.0.0:11434. El pedido va a /api/generate con el nombre del modelo, el texto (prompt) y "stream": false, para recibir la respuesta entera de una vez en el campo response. Los datos no salen de la escuela y no hay clave ni costo por pedido; a cambio, el computador necesita quedar encendido y tener memoria para el modelo.
Una API en la nube. Empresas como Anthropic, que hace Claude, ofrecen modelos mediante una API de pago, accedida con una clave secreta. Los modelos son mayores y más capaces, y el computador de la escuela no necesita hacer nada; a cambio, cada pedido cuesta, los datos salen hacia la empresa y la clave necesita guardarse muy bien.
Por qué no llamar al LLM directo desde la placa
Parece natural poner la clave de la API en el main.py y llamar a la nube directo desde la ESP32. No lo hagas. La clave es como una contraseña que gasta dinero: quien tenga acceso a la placa — basta conectarla a un computador y listar los archivos — copia la clave y pasa a hacer pedidos en tu cuenta. La propia Anthropic recomienda, en las buenas prácticas de claves, nunca ponerlas en código distribuido y guardarlas en variables de entorno o en una bóveda de secretos.
La arquitectura segura tiene un servidor intermedio tuyo — un computador de la escuela, un servicio de hospedaje — entre la placa y el LLM, como en la imagen. Él:
- guarda la clave de la API en una variable de entorno, fuera del código;
- reconoce a la placa por un token propio, que, si se filtra, puede cambiarse sin tocar la cuenta de la API;
- limita cuántos pedidos hace cada placa por hora, para que un bucle con error no vacíe el saldo en una tarde;
- arma el prompt con las instrucciones y devuelve a la placa solo un JSON corto, ya revisado.
El servidor también alivia a la placa: las respuestas de la API vienen en JSON grande y por HTTPS, y cada byte de menos es memoria de más para el programa de la ESP32.
Pedir en JSON y revisar la respuesta
Un LLM responde texto, y el texto puede venir en cualquier formato — incluso equivocado. La placa nunca debe ejecutar lo que el modelo escribió como si fuera un comando confiable. El patrón que funciona tiene tres partes:
- el pedido le dice al modelo exactamente qué formato devolver: un JSON con una
acaodentro de una lista; - la respuesta se lee con
json.loadsdentro de untry, porque puede no ser JSON; - la
acaose revisa en una lista blanca de comandos que la placa conoce, y los valores se limitan a rangos seguros. Lo que no esté en la lista se ignora.
El ejemplo arma el pedido y prueba la revisión con cinco respuestas posibles, entre ellas una inventada y una que intenta pedir un pin que no existe en el proyecto. Corre entero en el simulador, sin red.
import json
ACOES = {'ventilar', 'aquecer', 'nada'} # lo que esta placa sabe hacer
PINOS_PERMITIDOS = {2, 4} # y dónde
def montar_pedido(temp, umid):
return json.dumps({
'leitura': {'temperatura': temp, 'umidade': umid},
'instrucao': 'Responde solo un JSON {"acao": "ventilar"|"aquecer"|"nada", "pino": 2|4, "motivo": texto curto}',
})
def conferir(texto):
try:
r = json.loads(texto)
except ValueError:
return None, 'la respuesta no es JSON'
if not isinstance(r, dict):
return None, 'la respuesta no es un objeto'
acao = r.get('acao')
if acao not in ACOES:
return None, 'acción desconocida: %r' % (acao,)
pino = r.get('pino', 2)
if pino not in PINOS_PERMITIDOS:
return None, 'pin fuera de la lista: %r' % (pino,)
return (acao, pino), str(r.get('motivo', ''))[:40]
print('pedido:', montar_pedido(31.2, 78))
print()
respostas = [
'{"acao": "ventilar", "pino": 2, "motivo": "calor e umidade altos"}',
'{"acao": "nada", "motivo": "ambiente ok"}',
'¡Claro! Creo que deberías encender el ventilador.',
'{"acao": "abrir_porta", "pino": 2}',
'{"acao": "aquecer", "pino": 13}',
]
for texto in respostas:
comando, info = conferir(texto)
if comando and comando[0] == 'nada':
print('acepta: nada que hacer (%s)' % info)
elif comando:
print('EJECUTA %s en el GPIO%d (%s)' % (comando[0], comando[1], info))
else:
print('ignora:', info)Solo dos de las cinco respuestas se aceptan, y solo una enciende algo. Las otras tres — texto suelto, una acción que la placa no tiene, un pin fuera de la lista — se descartan sin riesgo. Esa revisión es lo que separa un proyecto de demostración de un proyecto en el que se puede confiar.
El código de la placa
Con un Ollama en la red local, la ESP32 conversa directo con él, sin clave. El campo format: "json" le pide a Ollama una salida en JSON, y stream: False hace que la respuesta llegue de una vez. La dirección y el nombre del modelo son ejemplos: usa los del computador de la escuela.
import json
import requests
OLLAMA = 'http://192.168.0.50:11434/api/generate' # computador con Ollama
def perguntar(prompt):
r = requests.post(OLLAMA, json={
'model': 'nombre-del-modelo',
'prompt': prompt,
'format': 'json',
'stream': False,
}, timeout=30)
try:
if r.status_code != 200:
return None
return r.json()['response'] # el texto del modelo, para pasar por conferir()
finally:
r.close()Con el servidor intermedio, la placa envía solo las lecturas y su token; quien habla con el LLM es el servidor.
import requests
SERVIDOR = 'https://mi-servidor.ejemplo/api/decidir'
TOKEN_DA_PLACA = 'token-de-esta-placa' # no es la clave de la API: es un token solo de tu servidor
r = requests.post(SERVIDOR, json={'temperatura': 31.2, 'umidade': 78},
headers={'Authorization': 'Bearer ' + TOKEN_DA_PLACA}, timeout=20)
try:
if r.status_code == 200:
comando = r.json() # ya revisado por el servidor; la placa revisa de nuevo
finally:
r.close()Del lado del servidor, un ejemplo en Node.js con el SDK oficial de Anthropic (@anthropic-ai/sdk). La clave viene de la variable de entorno ANTHROPIC_API_KEY; el modelo claude-haiku-5-5 es de la línea Haiku, la de los modelos menores y más económicos, y basta para respuestas cortas como esta.
import http from 'node:http';
import Anthropic from '@anthropic-ai/sdk';
const cliente = new Anthropic(); // lee ANTHROPIC_API_KEY del ambiente
const TOKENS = new Set([process.env.TOKEN_PLACA_1]);
const ACOES = new Set(['ventilar', 'aquecer', 'nada']);
http.createServer(async (req, res) => {
const token = (req.headers.authorization ?? '').replace('Bearer ', '');
if (req.method !== 'POST' || !TOKENS.has(token)) { res.writeHead(401).end(); return; }
let corpo = '';
for await (const parte of req) corpo += parte;
const { temperatura, umidade } = JSON.parse(corpo);
const msg = await cliente.messages.create({
model: 'claude-haiku-5-5',
max_tokens: 100,
system: 'Controlas un aula. Responde solo un JSON {"acao": "ventilar"|"aquecer"|"nada", "pino": 2|4, "motivo": texto corto}.',
messages: [{ role: 'user', content: `Temperatura ${Number(temperatura)} °C, humedad ${Number(umidade)} %.` }],
});
const texto = msg.content.filter((b) => b.type === 'text').map((b) => b.text).join('');
let resposta = { acao: 'nada', pino: 2, motivo: 'respuesta inválida' };
try {
const r = JSON.parse(texto);
if (ACOES.has(r.acao) && [2, 4].includes(r.pino)) resposta = r;
} catch { /* queda la respuesta segura */ }
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(resposta));
}).listen(process.env.PORT ?? 3000);Para un proyecto de verdad, faltarían el límite de pedidos por hora y el HTTPS, pero lo esencial está ahí: la clave solo en el servidor, el token de la placa revisado, el pedido armado por el servidor y la respuesta revisada dos veces, en el servidor y en la placa.
Los dos juntos
El tercer camino junta el ejemplo del potenciómetro con el del LLM: la placa usa el modelo pequeño y solo hace el pedido de red cuando la lectura cae en el "inseguro". En un sensor que mide cada minuto, eso puede significar una pregunta al servidor por día en lugar de 1440 — menos costo, menos espera y mucha menos batería. El artículo baterías en la ESP32 muestra cuánto pesa el radio en la cuenta: cada pedido HTTP mantiene el Wi-Fi encendido por segundos.
Del programa al semestre planificado
- Plan de enseñanza y calendario montados solos
- Diario de clases listo para pegar en el SIGA
- Diapositivas y listas de ejercicios con solucionario
Versión Community gratis · Windows
Cuidados que valen para los tres caminos
- El modelo se equivoca. Un modelo pequeño se equivoca cuando la situación es diferente de los datos de entrenamiento; un LLM se equivoca con frases convincentes. Nada que pueda lastimar a alguien o dañar algo — un motor, un calentador, una cerradura — debe depender solo de la respuesta de un modelo. Los límites físicos (un final de carrera, un termostato) y los límites en el programa quedan siempre.
- El texto que viene de afuera es dato, no orden. Si la placa manda al LLM un texto que vino de otra persona (un mensaje, un nombre escrito), ese texto puede contener instrucciones para el modelo. La lista blanca en la placa es la última barrera.
- Privacidad. Lecturas de una sala, sonidos e imágenes dicen mucho sobre quien está ahí. Prefiere procesar en la placa o en la red local, y envía a la nube solo lo necesario.
- La red falla. Usa
timeouten todo pedido y ten siempre una decisión por defecto segura para cuando el servidor no responda.
Para continuar
La ruta Analógico enseña a leer el potenciómetro y la lección media móvil muestra cómo limpiar una lectura antes de pasarla al modelo. La ruta Sensores trae el DHT22, que provee los datos del ejemplo del kNN, y la ruta Wi-Fi y HTTP cubre el lado de la red. Para guardar los ejemplos recolectados, mira el artículo guardar datos en la ESP32; para servir una página con el resultado del modelo, el artículo servidor web en la ESP32. Las fichas de la ESP32 DevKit V1, del potenciómetro y del DHT22 completan el armado.
Fuentes
- emlearn-micropython: modelos disponibles, módulos nativos
.mpyy arquitecturas probadas. - Documentación de Ollama: preguntas frecuentes: puerto 11434, dirección por defecto y
OLLAMA_HOST. - Documentación de Ollama: /api/generate: campos
model,prompt,format,streamyresponse. - SDK en TypeScript/JavaScript de Anthropic:
messages.createy la clave enANTHROPIC_API_KEY. - Buenas prácticas para claves de API (Anthropic): no poner claves en el código y guardarlas en variables de entorno.
- Documentación de MicroPython: requests: el módulo HTTP de la ESP32.
Para practicar en PyPlaca
Rutas
- Ruta 4: Analógico: ADC, potenciómetro y LDR
- Ruta 5: Sensores: temperatura, distancia y presencia
- Ruta 8: Wi-Fi y HTTP