ESP32 desde cero

Querés que tu planta te avise cuando tiene sed. Para eso hay que entender, desde lo más básico, cómo piensa una plaquita de unos pocos dólares.

Empezamos por qué es la electricidad, pasamos por la nube y terminamos con una notificación en el celular. Cada lección explica una sola idea.

Cómo usar esta guía: seguí el orden, una lección por vez. Cada una se apoya en la anterior. El código está en los cuadros ▸ Ver código: no hace falta abrirlos para entender la idea. Al final de cada lección marcás "Entendido" y la barra avanza.

Los cuadros 📓 Lo que te pasó a vos cuentan lo que hicimos en tu primera prueba, el 24 de octubre de 2026.
Parte A

Fundamentos

Lo mínimo de electricidad para que todo lo demás tenga sentido.

Lección 1 · 5 min

Electricidad en 5 minutos

La electricidad es un flujo de cargas que necesita un empujón y un camino de ida y vuelta.

Pensalo como agua en una cañería. Hay tres palabras que vas a ver todo el tiempo:

  • Voltaje (V, voltios): la presión, cuánto empuja la electricidad. La ESP32 trabaja con 3,3 V.
  • Corriente (A, amperes; mA, miliamperes): el caudal, cuánta electricidad pasa por segundo.
  • GND (tierra): el punto de referencia, que vale 0 V. Es el desagüe por donde vuelve la electricidad.
Fuente el pin 3V3 Componente sensor, LED… ida: 3,3 V (alta presión) vuelta: GND, 0 V (desagüe) Si se corta cualquiera de los dos cables, no circula nada.
Un circuito siempre es un círculo. Por eso cada componente lleva al menos dos cables: uno de energía y uno a GND.

Por qué importa GND

Un voltaje siempre se mide entre dos puntos. Decir "este cable tiene 2 V" significa "2 V más que GND". Por eso la placa y el sensor tienen que compartir el mismo GND: si no, cada uno mide contra un cero distinto y los números no significan nada.

GND es como el 0 de un eje en un gráfico. Sin un cero compartido, comparar dos valores no tiene sentido.

Dos reglas para no romper nada

  • Nunca unas 3V3 (o 5V) directamente con GND. Eso es un cortocircuito: la corriente pasa sin freno, calienta y puede quemar la placa.
  • Los pines de la ESP32 aguantan hasta 3,3 V. Si les llega 5 V se pueden dañar. Si un sensor funciona con 5 V, primero revisá cuánto voltaje entrega en su salida.
Voltaje = presión, corriente = caudal, GND = 0 V compartido. Siempre un circuito cerrado.
Lección 2 · 3 min

Digital y analógico

Algunas cosas son sí o no. Otras son "cuánto". La humedad es "cuánto".

Un botón está apretado o no lo está. Eso es digital: dos estados. Para la ESP32:

  • HIGH (1) = cerca de 3,3 V
  • LOW (0) = cerca de 0 V

La tierra no está "húmeda" o "seca" sin más. Está un poco húmeda, bastante, muy mojada. Eso es analógico: cualquier voltaje entre 0 y 3,3 V.

Para que tu código pueda usar un voltaje analógico, la placa tiene un ADC (conversor analógico-digital). Mide el voltaje y lo convierte en un número entero: con 12 bits, de 0 a 4095.

0 V → 0·~1,5 V → ~2000·~3,1 V → 4095
Digital es un checkbox. Analógico es un slider de 0 a 4095. Vos no lo movés: lo mueve el sensor.
¿Por qué 4095? (bits, desde cero)

Cada bit duplica la cantidad de valores posibles. Con 1 bit hay 2 valores (0 y 1). Con 12 bits hay 212 = 4096 valores, del 0 al 4095.

Más bits significan escalones más finos. Con 12 bits y un rango de ~3,1 V, cada escalón vale menos de 1 milivoltio.

Digital = HIGH/LOW. Analógico = un voltaje que el ADC convierte en 0–4095.
Parte B

La placa

Qué hay sobre la placa, cómo arranca y cómo le mandás un programa.

Lección 3 · 5 min

¿Qué es la ESP32?

La ESP32 es una computadora diminuta que hace una sola cosa: lo que vos le programes.

Tu Mac tiene sistema operativo, ventanas y mil programas abiertos. La ESP32 no tiene nada de eso. Es un microcontrolador: un procesador, un poco de memoria y Wi-Fi/Bluetooth en un solo chip. Corre un único programa: el tuyo.

¿Para qué sirve algo tan limitado? Justamente para eso. Es barata, consume poco y puede quedarse prendida meses haciendo una tarea simple, como mirar la tierra de una maceta.

Imaginá un servidor que corre un solo proceso, sin nada más instalado. No tiene pantalla. Le hablás por un cable.

"ESP32", "chip", "módulo", "placa": cuál es cuál

  • El chip ESP32: el cerebro. El tuyo es un ESP32-D0WD-V3 con 2 núcleos a 240 MHz.
  • El módulo: la cajita metálica plateada. Adentro tiene el chip, la memoria flash y el cristal que marca el ritmo. La parte con zigzag que asoma es la antena de Wi-Fi.
  • La placa de desarrollo (NodeMCU): todo lo demás. Pone el módulo sobre una placa con USB, botones y pines para que lo puedas usar sin soldar.

Las partes de tu placa

ParteQué hace
Procesador (2 núcleos)Ejecuta tu programa.
RAM (~320 KB)Memoria de trabajo. Se borra al cortar la energía. Ahí viven las variables.
Flash (4 MB)El "disco": guarda tu programa aunque desenchufes la placa.
Wi-Fi y BluetoothRadio incorporada, con la antena del módulo.
Chip CP2102Traductor entre USB y el puerto serie de la ESP32. Por eso tu Mac ve un /dev/cu.usbserial-0001.
Regulador de voltajeBaja los 5 V del USB a los 3,3 V que usa el chip.
Botón ENReinicia la placa (como apagar y prender).
Botón BOOTSi lo mantenés apretado al reiniciar, la placa entra en "modo carga" en vez de correr tu programa. Normalmente arduino-cli lo hace solo.
LEDUno indica que hay energía. En muchas placas hay otro que se controla con el GPIO 2.

Tu placa: ESP32-D0WD-V3 (revisión 3.1), flash de 4 MB, cristal de 40 MHz y una MAC propia (la MAC es como el número de serie de la placa en una red).

Microcontrolador = procesador + memoria + radio, corriendo un solo programa.
Lección 4 · 4 min

Cómo arranca la placa

Al prenderse, el chip lee tu programa desde la flash. Si no puede leerla, no arranca nada.

Dentro del chip hay un programa minúsculo grabado de fábrica que nunca se borra: la ROM. Es lo primero que corre cada vez que la placa recibe energía o se reinicia.

Energíao botón EN ROM¿BOOT apretado? Modo cargaespera un programa Leer flashpor los pines 6–11 flash read errreinicia y vuelve a probar Tu programasetup() y loop() sínofallaok
El ciclo completo, desde que llega la energía hasta que corre tu programa. Si leer la flash falla, el chip se reinicia y lo vuelve a intentar sin fin (la línea punteada).

La flash está conectada al chip por seis pines: GPIO 6 a 11 (en tu placa dicen CLK, SD0, SD1, SD2, SD3, CMD). Si algo externo toca esos pines, la ROM no puede leer la flash y la placa no arranca.

Esto te pasó: la placa repetía flash read err, 988 en bucle y la flash respondía ff ffff, como si no existiera. El procesador funcionaba, así que se descartó el software. Cuando corregiste algo físico (cables, protoboard o la superficie donde apoyabas la placa), la flash respondió bien: 4 MB, fabricante c4.

ROM → lee la flash → corre tu programa. Los pines 6–11 son de la flash: no se tocan.
Lección 5 · 4 min

Del código a la placa

Escribís en tu Mac, lo traducís y lo mandás por el cable. Ese es el ciclo.

El chip no entiende el código que escribís, solo instrucciones de máquina. Hay que traducirlo.

✍️ Escribir→⚙️ Compilar→🔌 Cargar→👀 Mirar logs
  1. Escribir: un archivo .ino. Es C++, simplificado.
  2. Compilar: se traduce a un archivo binario que el chip entiende.
  3. Cargar (o "flashear"): el binario viaja por el USB y se guarda en la flash.
  4. Mirar logs: la placa te escribe mensajes por el mismo cable. Eso es el monitor serie.
Compilar es tu npm run build. Cargar es tu deploy. Mirar logs es abrir la consola. La diferencia es que el servidor lo tenés en la mano.

Una vez cargado, el programa queda guardado. Podés desenchufar la placa de la Mac, conectarla a un cargador de celular y arranca sola.

El puerto serie y los baudios

El puerto serie es un canal de texto que manda un bit atrás de otro. Los dos lados tienen que acordar la velocidad: los baudios. Si tu programa dice Serial.begin(115200), el monitor también tiene que estar en 115200. Si no coinciden, vas a ver caracteres basura.

Ver los comandos
# compilar
arduino-cli compile --fqbn esp32:esp32:esp32 ~/Documents/Arduino/humedad_suelo_prueba
# cargar
arduino-cli upload -p /dev/cu.usbserial-0001 --fqbn esp32:esp32:esp32 ~/Documents/Arduino/humedad_suelo_prueba
# mirar logs (salís con Ctrl+C)
arduino-cli monitor -p /dev/cu.usbserial-0001 -c baudrate=115200

esp32:esp32:esp32 le dice al compilador qué placa es (el "FQBN"). /dev/cu.usbserial-0001 es por dónde está conectada.

El puerto lo puede usar un solo programa a la vez. Si el monitor está abierto, no podés cargar ni leer desde otro lado. Cerrás el monitor, cargás y lo volvés a abrir.

Te pasó dos veces: con tu monitor abierto, mi lectura daba Resource busy. También viste miles de lecturas de golpe al abrir el puerto. No era la placa midiendo más rápido: eran lecturas viejas acumuladas en la Mac mientras nadie leía.

Escribir → compilar → cargar → mirar logs. Un programa por puerto, mismos baudios en los dos lados.
Lección 6 · 4 min

Un programa que nunca termina

Todo programa de ESP32 tiene dos partes: lo que pasa una vez, y lo que se repite para siempre.

Un script común arranca, hace su trabajo y termina. Algo que vigila una planta día y noche no puede terminar nunca. Por eso los programas se dividen así:

  • setup(): se ejecuta una sola vez, al prender la placa. Ahí preparás todo.
  • loop(): se ejecuta una y otra vez, hasta que le cortes la energía.
Es como un servidor: al arrancar configura todo (setup) y después atiende para siempre (loop). Una diferencia: acá no hay eventos que te despierten. Tu loop pregunta todo el tiempo "¿pasó algo?".
Ver código: el LED que parpadea
void setup() {
  pinMode(2, OUTPUT);        // el pin 2 (el LED) va a ser una salida
}

void loop() {
  digitalWrite(2, HIGH);     // prender: 3,3 V en el pin
  delay(500);                // esperar medio segundo
  digitalWrite(2, LOW);      // apagar: 0 V
  delay(500);
}                            // ...y vuelve a empezar

delay() no se parece a setTimeout: congela toda la placa mientras espera. Para esperar unos segundos está bien. Más adelante vas a aprender a esperar sin congelar nada (con millis()).

setup() una vez, loop() para siempre.
Parte C

Los pines

Las patitas por donde la placa siente y actúa. Qué sabe hacer cada una.

Lección 7 · 6 min

Qué puede hacer un pin

Tu código no ve la tierra ni toca un LED. Lo que hace es poner o medir voltaje en una patita.

A los pines que tu código controla se los llama GPIO ("entrada/salida de uso general"). Cada uno tiene un número: GPIO 2, GPIO 34, etc. En la placa a veces figura solo el número.

1. Salida digital: la placa actúa

El pin pone 3,3 V (HIGH) o 0 V (LOW). Así se prende un LED o se activa un relé.

  • Cada pin entrega poca corriente: lo seguro es hasta ~12 mA. Alcanza para un LED con su resistencia, no para un motor.

2. Entrada digital: la placa mira

El pin dice si recibe algo cercano a 3,3 V o a 0 V. Así se lee un botón.

  • Pin flotante: si no hay nada conectado, el pin capta ruido y da valores al azar. Por eso existen las resistencias pull-up / pull-down, que lo "tironean" a un valor fijo cuando nadie le habla. La ESP32 las tiene adentro: pinMode(4, INPUT_PULLUP).

3. Entrada analógica (ADC): la placa mide cuánto

El pin convierte un voltaje en un número de 0 a 4095 (lección 2). La ESP32 tiene dos ADC:

  • ADC1 (GPIO 32–39): funciona siempre. Es el que usás para sensores.
  • ADC2 (GPIO 0, 2, 4, 12–15, 25–27): deja de funcionar cuando el Wi-Fi está prendido, porque el Wi-Fi lo usa.

4. Otras habilidades que vas a cruzarte

HabilidadQué esPara qué
PWMPrender y apagar muy rápido. El promedio parece un voltaje intermedio.Regular el brillo de un LED, la velocidad de un motor.
TouchDetecta cuando lo tocás con el dedo.Botones sin partes móviles.
DAC (GPIO 25, 26)Lo inverso del ADC: de número a voltaje real.Generar sonido o una señal.
UART (serie)Dos cables: TX (envía) y RX (recibe).Hablar con la Mac o con un GPS.
I²C (SDA 21, SCL 22)Dos cables compartidos por muchos dispositivos, cada uno con su dirección.Pantallas chicas y sensores de temperatura.
SPI (18, 19, 23, 5)Cuatro cables, más rápido que I²C.Tarjetas SD y pantallas grandes.
Un pin de salida es escribir en el DOM. Uno de entrada es leer el value de un input. I²C y SPI son como protocolos de red: varios dispositivos sobre los mismos cables.

5. Pines con condiciones

  • Solo entrada (GPIO 34, 35, 36, 39): no pueden sacar voltaje ni tienen pull-ups internos. Son ideales para sensores analógicos.
  • Strapping (GPIO 0, 2, 5, 12, 15): la ROM los mira en el instante del arranque para decidir cómo arrancar. Si algo externo los fuerza a un valor raro, la placa puede no arrancar.
  • Flash (GPIO 6–11): ocupados por la memoria. No se usan nunca.
  • Serie USB (GPIO 1 TX, 3 RX): los usa el CP2102 para hablar con la Mac. Si conectás algo ahí, cargar o leer logs puede fallar.
Salida, entrada digital, entrada analógica. Sensores en ADC1. Los pines 6–11 no se tocan.
Lección 8 · explorá a tu ritmo

Mapa de los 38 pines

Un semáforo: verde se usa tranquilo, amarillo con cuidado y rojo nunca.

Así se ve tu NodeMCU desde arriba, con la antena arriba y el USB abajo. Tocá cualquier pin para ver qué hace, o filtrá por color.

antena Wi-Fi
ESP32módulo WROOM
ENBOOT
USB-C

Tocá un pin para ver su detalle. Los que tienen borde celeste son los que usa tu sensor.

Ver la tabla completa de pines
EtiquetaGPIOTipoFuncionesNota

Las etiquetas impresas pueden variar un poco entre fabricantes de clones. Si tu placa dice algo distinto, hacele caso a la serigrafía (lo impreso) y comparalo con este mapa por posición.

Para sensores analógicos: 32–39. Para salidas: los verdes. Rojo nunca.
Parte D

El sensor de humedad

Cómo mide, cómo se conecta, qué pasó en tu prueba y qué falta.

Lección 9 · 5 min

Cómo mide la humedad

El sensor no toca el agua: siente cuánta agua tiene cerca.

Desde cero: el capacitor

Un capacitor son dos superficies de metal que no se tocan. Si le aplicás voltaje, guarda un poquito de carga, como un tanque chico que se llena y se vacía.

Cuánta carga guarda depende de qué hay alrededor de esas superficies. El agua hace que guarde muchísimo más que el aire:

Aire: ×1Tierra seca: ×3 a ×5Agua: ×80

Tu sensor V1.2 tiene dos pistas de cobre adentro de la varilla negra, cubiertas por barniz. Esas pistas son el capacitor. Al clavarlo, la tierra y el agua que contiene quedan alrededor.

De capacitor a voltaje

Chip 555señal on/off rapidísima Pistas + tierramás agua = más amortigua Convertidorseñal → voltaje estable AOUT~1 a 2,5 V Más agua → señal más débil → voltaje más bajo → número más bajo
Por eso el número baja cuando hay más humedad. Parece al revés, pero es así como funciona.

Es un sensor "capacitivo" porque mide cambios de capacitancia. Como el cobre no queda expuesto, no se oxida: por eso es mejor que el sensor de dos patitas de metal con forma de horquilla.

Solo la parte de abajo de la línea marcada en la varilla está protegida. Arriba están los componentes, sin barniz: si se mojan, el sensor deja de medir bien o se arruina.

Agua cerca de las pistas → más capacitancia → voltaje más bajo.
Lección 10 · 5 min

Conectarlo y leerlo

Tres cables: energía, retorno y señal. Y un programa de diez líneas.

Sensor capacitivo V1.2 ↑ siempre seco ↓ se entierra hasta la línea VCC GND AOUT energía 3,3 V retorno 0 V señal ~1 a 2,5 V 3V3 GND P34 (GPIO34) ESP32 ADC1 · 12 bits · 11 dB voltaje → 0…4095 USB-C Mac 115200 CP2102
En tu placa, P34 es el 5.º pin de la columna izquierda contando desde arriba (3V3, EN, VP, VN, P34). Para GND sirve cualquiera de los pines GND: están todos unidos.

Por qué estos pines

  • 3V3 y no 5V: así la señal del sensor nunca supera lo que aguanta el pin.
  • GPIO 34: es de ADC1 (sigue funcionando con Wi-Fi) y es solo entrada, justo lo que hace falta para leer.

El programa

Está en ~/Documents/Arduino/humedad_suelo_prueba/.

const int PIN_SENSOR = 34;

void setup() {
  Serial.begin(115200);                          // abrir los logs a 115200 baudios
  analogReadResolution(12);                      // valores de 0 a 4095
  analogSetPinAttenuation(PIN_SENSOR, ADC_11db); // rango de entrada ~0 a 3,1 V
}

void loop() {
  int crudo = analogRead(PIN_SENSOR);            // medir el voltaje
  Serial.println(crudo);                         // mandarlo a la Mac
  delay(1000);                                   // una vez por segundo
}

¿Qué es la atenuación?

El ADC, por sí solo, solo mide hasta ~1 V. La atenuación achica la señal antes de medirla, para que entren voltajes más altos. Con ADC_11db el rango llega a ~3,1 V, suficiente para los ~1–2,5 V del sensor.

Detalle fino: el ADC no es perfecto

El ADC de la ESP32 es menos preciso cerca de 0 V y por encima de ~3 V. En el medio funciona bien, y ahí es justo donde trabaja este sensor. Además, cada lectura varía unas pocas unidades por ruido: es normal.

VCC→3V3, GND→GND, AOUT→GPIO34. 12 bits, 11 dB, un println por segundo.
Lección 11 hecho · 24/10/2026

Tu primera prueba

Que llegue un número estable no prueba nada. Hay que ver que cambie cuando cambia la humedad.

Lo que pasó, paso a paso

  1. Herramientas: Arduino CLI 1.5.1 y el soporte para ESP32 3.3.12 ya estaban instalados. Placa: esp32:esp32:esp32, puerto /dev/cu.usbserial-0001.
  2. La flash no respondía (ff ffff, flash read err). Se descartaron la velocidad del puerto y el modo de carga. No se reinstaló nada.
  3. Se arregló en lo físico. La flash respondió bien: 4 MB.
  4. Copia de seguridad del firmware de fábrica en ~/Documents/Arduino/backups/firmware_original_04b24753e728.bin.
  5. Programa cargado y verificado: llega una lectura por segundo.
  6. Prueba de la mano: el valor bajó casi 1000 unidades. El sensor responde.
  7. En la tierra no cambió: probablemente estaba muy seca. Falta la prueba con agua.

Las lecturas

Al aire 2557 Tierra (¿seca?) 2557 Apretado con la mano 1567 Soltándolo 1847 ↑ 0 4095 ← más húmedo · más seco →
Barras a escala real. Cuanto más corta la barra, más humedad detecta el sensor.
PruebaValorQué demuestra
Al aire2555–2559Llega una señal estable al pin. Solo con eso no alcanza.
Enterrado2555–2559Sin cambio: probablemente tierra seca.
Apretado con la mano1567–1618El sensor responde. El pin, el ADC y el chip del sensor funcionan.
Soltándolo1680 → 1847Sube de a poco a medida que se va la humedad de la mano.

¿Por qué la mano? Tiene mucha agua, así que es una prueba rápida de "¿responde el sensor?" sin ensuciar nada. Si con la mano no cambia, el problema está en el sensor o el cableado, no en la tierra.

Una prueba vale si el número cambia cuando cambia la humedad.
Lección 12 tu próximo paso

Calibrar

"2142" no dice nada solo. Necesita dos puntos de referencia: seco y mojado.

Con dos extremos, cualquier lectura se convierte en un porcentaje con una regla de tres. La pregunta es qué extremos usar.

Por qué no alcanza con "aire" y "vaso de agua"

Una tierra para macetas recién regada tiene entre 60 y 80 % de agua en volumen: nunca llega a lo que mide el sensor en un vaso. Calibrando con el vaso, tu planta recién regada marcaría algo como 70 %, nunca 100 %.

Mejor: calibrar en la maceta real

  1. "Regada": regá la difenbaquia, esperá unos 30 minutos a que escurra y mirá el valor en el gráfico.
  2. "Hay que regar": cuando al meter el dedo los primeros 2–3 cm estén secos, mirá el valor. Ese es el umbral para el aviso.

No hace falta anotar nada a mano: el historial ya guarda todo. Mientras tanto, el panel usa una calibración provisoria (2557 al aire, 1500 con la mano) en lib/humedad.ts.

Con el sensor clavado en tu difenbaquia, el primer valor fue 2137–2144, bastante por debajo de los 2557 al aire. La tierra tiene humedad y el sensor la detecta.

Primer riego real, 27/10/2026 a las 08:30. Antes de regar la placa marcaba 2173 (~36 % con la calibración provisoria); en la lectura siguiente, 1147.

  • El detector de riegos funcionó: el panel lo marcó solo, "de 36 % a 100 %". El salto fue de más de 1000 puntos y el mínimo para considerarlo riego es 120.
  • La calibración provisoria se quedaba corta: 1147 está muy por debajo de los 1500 de "mojado", así que el panel quedó clavado en 100 %. La tierra recién regada da una lectura más baja que el sensor agarrado con la mano.
  • Después del riego el valor subió y se estabilizó: 1147 → 1193 → 1220 → 1239 → 1245 → 1253 en las dos horas siguientes, mientras el agua escurría. Ese ~1250 quedó como el nuevo "húmedo". Después siguió subiendo de a poco (1269 a la medianoche, 1296 al otro día): la tierra se seca lento, como corresponde.
  • El punto seco, estimado: del 24 al 27/10 el valor casi no se movió (2130–2190). Pero era tierra nueva de bolsa, nunca regada: tenía la humedad que traía de fábrica y estaba suelta, con aire alrededor del sensor. El primer riego la asienta y la pega al sensor, así que esos números no se pueden comparar con los de después. Mientras tanto se usa 2250 como "hay que regar", una estimación.
  • Pendiente: el seco de verdad sale de este primer ciclo: cuando la planta vuelva a pedir agua (2–3 cm de arriba secos, hojas un poco caídas), anotar el valor antes de regar.
Es como normalizar datos: el sensor habla en "2557" y vos lo traducís a "35 %".
Ver código: de número a porcentaje (para cuando tengas el valor mojado)
const int SECO   = 2557;   // "hay que regar" (provisorio: al aire)
const int MOJADO = 1500;   // "regada" (provisorio: con la mano)

int humedad() {
  int valor = analogRead(34);
  int pct = map(valor, SECO, MOJADO, 0, 100);  // regla de tres
  return constrain(pct, 0, 100);               // que no se pase de 0–100
}

Secá bien el sensor entre pruebas. No salpiques los componentes de arriba ni la ESP32.

Dos puntos (seco y mojado) convierten el número crudo en porcentaje.
Parte E

Del sensor a la nube

Cómo una lectura viaja de la maceta a una página que ves desde cualquier lado, y cómo se hizo para que no se pierda.

Lección 13 · 5 min

De la placa a internet

La placa hace lo mismo que un fetch: un POST con JSON a una URL.

Hasta acá la placa le hablaba solo a tu Mac, por el cable. Para mandar datos a internet necesita dos cosas:

  1. Wi-Fi: se conecta a tu router como un celular y recibe una dirección en tu red (algo como 192.168.1.74).
  2. HTTP: el mismo idioma que usa el navegador. La placa arma un pedido y el servidor contesta.

Cómo es el pedido

POST /api/lecturas                         ← método y ruta
Content-Type: application/json             ← "te mando JSON"
Authorization: Bearer <token>              ← el token: "soy la placa"

{"dispositivo":"planta-1","lecturas":[{"crudo":2142,"medido_en":1792881667}]}

El token es una contraseña compartida entre la placa y el servidor. Sin él, cualquiera que conozca la URL podría mandar lecturas falsas. Vive en dos lugares: secrets.h en la placa y una variable secreta en Vercel. Nunca en git.

Cómo es la respuesta

Todo pedido vuelve con un código de estado. La placa decide qué hacer según ese número:

CódigoSignificaQué hace la placa
201Guardado.Borra esas lecturas de su memoria.
400Datos inválidos.Las descarta: reenviarlas no las va a arreglar.
401Token incorrecto.Las guarda y avisa por el monitor serie.
500Falló el servidor.Las guarda y reintenta en un minuto.
Es fetch(url, {method: "POST", headers, body: JSON.stringify(datos)}) escrito en C++ con la librería HTTPClient. Los códigos son los mismos que ves en la pestaña Network del navegador.

La primera versión le mandaba los datos a tu Mac (una dirección de la red de casa, del estilo http://192.168.1.x:3000). Funcionaba, pero tenía tres límites: la Mac tenía que estar prendida, despierta y en la misma red, con la misma IP. Por eso se pasó a la nube.

Wi-Fi para llegar, HTTP para hablar, token para identificarse, código de estado para saber qué pasó.
Lección 14 · 6 min

El backend: recibir, validar, guardar

El backend es el programa que atiende a la placa: revisa lo que llega y lo guarda en una base de datos.

Está hecho con Next.js, en app/api/lecturas/route.ts. Ese archivo tiene dos funciones: POST (la placa manda lecturas) y GET (la página pide el historial).

1. Validar: nunca confiar en lo que llega

Antes de guardar, el backend comprueba que el token coincida, que crudo sea un entero entre 0 y 4095, y que la hora no esté en el futuro ni tenga más de 7 días. Si algo no cierra, contesta 400 y no guarda nada.

2. Guardar: una base de datos es una planilla con reglas

La base es Postgres. Una tabla es como una planilla donde cada columna tiene un tipo fijo:

create table lecturas (
  id          bigint generated always as identity primary key,  -- número automático
  dispositivo text        not null,                              -- "planta-1"
  crudo       integer     not null check (crudo between 0 and 4095),
  creado_en   timestamptz not null default now()                 -- fecha y hora
);

Se le habla en SQL: insert agrega filas y select las lee.

insert into lecturas (dispositivo, crudo) values ('planta-1', 2142);
select crudo, creado_en from lecturas where dispositivo = 'planta-1' order by creado_en;

3. Guardar el número crudo, no el porcentaje

La base guarda 2142, no "39 %". El porcentaje se calcula al mostrar. Así, cuando calibres de nuevo, todo el historial se recalcula solo y no se pierde nada.

Local y nube, mismo código

En tu Mac la base es PGlite, un Postgres que corre dentro de Node y guarda los datos en una carpeta. En la nube es Supabase. El SQL es el mismo. Lo que decide cuál se usa es una sola variable: si existe DATABASE_URL, va a Supabase.

Es una API REST común: un route handler que valida el body y hace un INSERT. Lo único raro es que el cliente es una placa de 5 dólares.

Dos errores que enseñaron algo:

  • Un import a un archivo que todavía no existía rompió la compilación por un minuto. El backend respondió 500 y se perdieron 5 lecturas. Lección: crear primero lo que se importa.
  • Algo que andaba en local falló en producción: los dos conectores de Postgres trataban distinto un parámetro JSON. Se encontró leyendo los logs de Vercel. Lección: local no es igual a producción, siempre probar en el entorno real.
Validar todo, guardar el dato crudo, calcular el resto al mostrar.
Lección 15 · 6 min

La nube: Supabase y Vercel

Vercel corre tu código; Supabase guarda tus datos. Los dos están en São Paulo, cerca entre sí.

ESP32en la maceta Tu navegadoro el celular Vercelpágina + /api/lecturasregión gru1 (São Paulo)token en variable secreta SupabasePostgres (sa-east-1)usuario limitadoRLS activado POST (HTTPS + token) GET la página SQL por el pooler
Tu Mac ya no aparece en el dibujo: la placa habla directo con la nube.

Supabase: Postgres administrado

  • Es un Postgres que corre en un servidor que no tenés que mantener.
  • Pooler: en Vercel cada pedido abre una conexión corta. Postgres no aguanta miles de conexiones; el pooler las agrupa en unas pocas. Por eso la dirección usa el puerto 6543.

Seguridad en capas

  1. RLS (seguridad por fila): Supabase expone las tablas por una API pública. Con RLS activado y sin permisos para esa API, nadie puede leer ni escribir por ahí.
  2. Usuario con permisos mínimos: el backend no usa el administrador postgres sino un usuario propio que solo puede insertar y leer en lecturas. Se probó: no puede borrar ni crear tablas. Si algún día roban esa contraseña, el daño es mínimo.
  3. La contraseña no salió de tu Mac: a Supabase se le mandó solo su hash (SCRAM-SHA-256). Sirve para verificar la contraseña, no para recuperarla.

Vercel: el código en la nube

  • Funciones: tu backend no está prendido todo el tiempo; se ejecuta cuando llega un pedido.
  • Región gru1: si las funciones corrieran en Estados Unidos, cada lectura viajaría a Estados Unidos y volvería a São Paulo para guardarse. Ahora tarda unos 0,3 s.
  • Variables de entorno: DATABASE_URL y DEVICE_TOKEN están guardadas como secretas en Vercel. El código las lee con process.env.
  • Deploy automático: cada git push al repo despliega una versión nueva en unos 20 segundos.

Hubo una confusión de cuentas: el repo quedó en una cuenta de GitHub y la integración de Vercel buscaba en otra. Lección: antes de conectar servicios, fijate con qué cuenta estás en cada uno.

Vercel ejecuta, Supabase guarda. Permisos mínimos, secretos fuera de git, todo en la misma región.
Lección 16 · 5 min

HTTPS desde cero

HTTPS hace dos cosas: cifra lo que viaja y prueba que del otro lado está quien dice ser.

Sin HTTPS, cualquiera en el camino (el Wi-Fi de un vecino, un router intermedio) podría leer el token y usarlo para mandar datos falsos. Con HTTPS ve solo bytes sin sentido.

¿Cómo sabe la placa que habla con Vercel?

Con un certificado: el DNI del sitio, firmado por una autoridad. Esa autoridad a su vez está firmada por otra, y así hasta llegar a un certificado raíz, en el que se confía de antemano.

*.vercel.applo manda el servidor WR1autoridad intermedia GTS Root R1guardado en la placa Si la cadena no termina en un certificado raíz que la placa conoce, no envía nada. firmadopor →firmadopor →

Tu navegador trae cientos de certificados raíz. La placa solo los que le cargamos en certificados.h: GTS Root R1 (de Google, el que usa Vercel hoy) e ISRG Root X1 (Let's Encrypt, de respaldo). Vencen en 2036 y 2035.

¿Por qué la placa necesita saber la hora?

Los certificados tienen fecha de vencimiento. La placa arranca creyendo que es 1970, así que todos le parecerían inválidos. Por eso, antes del primer envío, le pregunta la hora a un servidor de tiempo (NTP).

Mirá la cadena de certificados vos mismo:

echo | openssl s_client -connect planta-monitor-pearl.vercel.app:443 2>/dev/null | grep -E "s:|i:"

Existe un atajo, setInsecure(), que se saltea la verificación. Funciona, pero entonces la placa le daría el token a cualquiera que se haga pasar por Vercel. Acá se usó la verificación real.

HTTPS = cifrado + identidad. La identidad se prueba con una cadena de firmas que termina en un certificado raíz conocido.
Lección 17 · 7 min

Que no se pierda nada: robustez

Un dispositivo que funciona solo tiene que suponer que todo va a fallar alguna vez: el Wi-Fi, el servidor y él mismo.

Tres memorias, tres duraciones

MemoriaSobrevive a un reinicioSobrevive a un corte de luzUso
RAMNoNoVariables comunes.
RTC (8 KB)SíNoEl buffer de lecturas pendientes.
Flash (4 MB)SíSíEl programa. Se gasta si se escribe muy seguido.

1. Buffer circular: guardar lo que no se pudo mandar

Cada lectura entra primero a un buffer. Solo sale cuando el servidor confirma con 201. Es circular: tiene 480 lugares (8 horas) y, si se llena, la lectura nueva pisa a la más vieja. Vive en la memoria RTC (RTC_NOINIT_ATTR), así que sobrevive a cuelgues y reinicios.

Leercada 1 min Buffer RTChasta 480 Enviar lotehasta 30, con hora 201 → se borran del buffer 400 → se descartan sin red / 500 → quedan

2. Lotes con la hora real de medición

Si el Wi-Fi vuelve después de una hora, la placa manda las 60 lecturas juntas y cada una con medido_en, la hora en que se midió. En el gráfico aparecen en su lugar, no amontonadas en el momento de reconexión.

3. Reenviar sin duplicar (idempotencia)

¿Qué pasa si el servidor guardó, pero la respuesta se perdió? La placa no sabe y reenvía. Para que eso no duplique datos, la base tiene una regla: no puede haber dos lecturas del mismo dispositivo con la misma hora (índice único). El insert usa on conflict do nothing: lo repetido se ignora en silencio.

Es la misma idea que las idempotency keys de las APIs de pago: el mismo pedido dos veces produce el mismo resultado que una.

4. Watchdog: el perro guardián

Es un temporizador de hardware. El programa le dice "sigo vivo" (esp_task_wdt_reset()) en cada vuelta del loop(). Si pasan 2 minutos sin noticias, porque algo se trabó, el watchdog reinicia la placa. Gracias al buffer en RTC, lo pendiente no se pierde.

5. Último recurso y filtro de ruido

  • Si pasa 1 hora sin poder enviar nada, la placa se reinicia completa: a veces el Wi-Fi o la red quedan trabados de formas que solo un reinicio arregla.
  • Cada lectura es la mediana de 9 mediciones: se ordenan y se toma la del medio. Un pico de ruido aislado queda en un extremo y no afecta.

Lo que no cubre: un corte de luz borra la memoria RTC. Con conexión normal eso significa perder, como mucho, la última lectura. Y la recuperación ante cortes de Wi-Fi todavía no se probó en la placa real: probala apagando el router 5 minutos.

Guardar antes de enviar, borrar solo con confirmación, reenviar sin duplicar, y un watchdog por si todo lo demás falla.
Lección 18 · 5 min

El panel y la planta 3D

La página pide el historial cada 10 segundos y lo dibuja de dos formas: un gráfico y una planta que reacciona.

Del número crudo al porcentaje

La página usa lib/humedad.ts, que hace la regla de tres de la lección 12: 0 % es el momento de regar y 100 %, recién regada. Con el porcentaje elige el estado: hasta 10 % "Le toca agua", hasta 35 % "Se está secando", hasta 85 % "Bien de agua" y, arriba de eso, "Húmeda" (o "Recién regada" si el riego fue en las últimas 24 h).

three.js desde cero

Una escena 3D tiene siempre las mismas piezas:

  • Escena: el mundo donde se ponen las cosas.
  • Cámara: desde dónde se mira. Acá gira sola despacio y la podés arrastrar.
  • Luces: sin luz, todo se ve negro. Hay una luz general, una principal desde arriba y un relleno desde la cámara.
  • Mallas (meshes): cada objeto es una geometría (la forma) más un material (color, textura, brillo). La maceta es una silueta que gira 360° con 9 caras. Cada hoja es un contorno dibujado con curvas, con las manchas crema generadas por código.
  • Bucle de animación: unas 60 veces por segundo (useFrame), el código acerca de a poco la planta a la humedad actual. Si falta agua, las hojas se inclinan y amarillean, y la tierra se aclara.
Es como animar con requestAnimationFrame: en cada cuadro se cambian propiedades (ángulo, color) en vez de re-renderizar React. React Three Fiber es el puente entre React y three.js.

Agregá ?simular=10 o ?simular=85 al final de la URL para ver la planta seca o recién regada sin mojar nada.

El dato crudo vive en la base; el porcentaje, el estado y la planta 3D se calculan al mostrar.
Parte F

Lo que sigue

Cuando la lectura sea confiable: decidir cuándo avisar y mandar la notificación.

Lección 19 · 3 min

Cuándo avisar (sin volverte loco)

"Avisame si baja de 30 %" suena perfecto. Hasta que te llegan 40 notificaciones.

Las lecturas tiemblan un poco (ya lo viste: 2555, 2557, 2559). La humedad puede quedar oscilando entre 29 % y 31 % durante una hora. Cada vez que baja de 30, te avisaría.

La solución es usar dos límites en vez de uno (se llama histéresis):

  • Avisás cuando baja de 30 %.
  • No volvés a avisar hasta que suba de 45 %, o sea, hasta que la riegues.
Es la misma idea que un debounce: ignorás los temblores y reaccionás solo a los cambios reales.
Ver código
bool yaAvise = false;

void revisar(int pct) {
  if (!yaAvise && pct < 30) {
    avisar("🌱 Tengo sed");
    yaAvise = true;
  }
  if (yaAvise && pct > 45) {
    yaAvise = false;     // me regaron: listo para el próximo aviso
  }
}
Dos límites: uno para avisar, otro para rearmar.
Lección 20 · 4 min

El aviso al celular

La placa se conecta al Wi-Fi y hace un POST. Igual que un fetch.

Acá vuelve terreno conocido. El servicio más simple para notificaciones es ntfy:

  1. Instalás la app ntfy en el celular.
  2. Te suscribís a un "tema" con un nombre difícil de adivinar, por ejemplo planta-santi-x7k2.
  3. Cada vez que la placa hace un POST a ntfy.sh/planta-santi-x7k2, te llega una notificación.
Es literalmente fetch("https://ntfy.sh/planta-santi-x7k2", {method: "POST", body: "Tengo sed 🌱"}), solo que escrito en C++.

Probalo desde la terminal antes de tocar la placa: instalá la app, suscribite a tu tema y corré curl -d "hola 🌱" ntfy.sh/tu-tema.

Ver código
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>

void setup() {
  WiFi.begin("TU_RED", "TU_CLAVE");
  while (WiFi.status() != WL_CONNECTED) delay(300);   // esperar conexión
}

void avisar(String mensaje) {
  WiFiClientSecure cliente;
  cliente.setInsecure();          // simplifica HTTPS, está bien para aprender
  HTTPClient http;
  http.begin(cliente, "https://ntfy.sh/planta-santi-x7k2");
  http.POST(mensaje);
  http.end();
}

La ESP32 solo se conecta a redes Wi-Fi de 2,4 GHz. Si tu router tiene dos redes (una dice "5G"), usá la otra. Y por esto el sensor va en ADC1: con el Wi-Fi prendido, el ADC2 deja de funcionar.

Wi-Fi + POST a ntfy = notificación en el celu.
Referencia

Si algo falla

Regla general: primero entendé el error, después cambiá cosas. Reinstalar herramientas casi nunca es la respuesta.

SíntomaCausa probableQué hacer
flash read err en bucle, flash ff ffffAlgo toca los pines 6–11, protoboard o superficie conductora, o placa fallada.Desconectá todo, apoyala sobre la mesa y probá de nuevo.
Resource busy o la carga falla al conectarOtro programa tiene el puerto abierto (el monitor).Cerrá el monitor con Ctrl+C.
Caracteres basura en el monitorLos baudios no coinciden.Mismo número en Serial.begin() y en el monitor.
Miles de lecturas de golpeDatos acumulados mientras nadie leía.Esperá un segundo: se normaliza solo.
Números al azarPin flotante: el cable de señal está suelto.Revisá el cable de AOUT.
Valor fijo aunque cambie la humedadTierra muy seca, cable de AOUT en otro pin o un sensor con chip NE555 que no funciona a 3,3 V.Prueba de la mano, P34 a GND (≈0) y a 3V3 (≈4095), y mirar qué dice el chip del sensor.
La placa no aparece en /dev/cu.*Cable USB que solo carga (no transmite datos).Probá con otro cable.
El panel dice "sin datos de la placa"La placa no envió nada en 2 minutos: sin energía, sin Wi-Fi o una batería externa que se apagó sola.Revisá el cargador. Si sigue, conectala a la Mac y mirá el monitor serie.
Monitor: HTTP 401El token de secrets.h no coincide con el de Vercel.Copiá el mismo valor en los dos y volvé a cargar el firmware.
Monitor: No se pudo enviar (-1 …) repetidoSin internet, falla de HTTPS o la hora no se sincronizó.Las lecturas quedan guardadas. Si dura mucho, revisá el router.
La API responde 500Error en el backend.npx vercel logs --since 30m dentro de la carpeta del proyecto.
Funciona en local y falla en producciónLocal no es igual a producción (otra base, otro conector, otras variables).Leé los logs de producción y probá contra la base real.
Referencia

Comandos y archivos

# ver qué está conectado
arduino-cli board list

# compilar y cargar el programa del sensor
arduino-cli compile --fqbn esp32:esp32:esp32 ~/Documents/Arduino/humedad_suelo_prueba
arduino-cli upload  -p /dev/cu.usbserial-0001 --fqbn esp32:esp32:esp32 ~/Documents/Arduino/humedad_suelo_prueba

# mirar las lecturas (Ctrl+C para salir)
arduino-cli monitor -p /dev/cu.usbserial-0001 -c baudrate=115200

# ¿quién está usando el puerto?
lsof /dev/cu.usbserial-0001

# cargar el firmware con Wi-Fi (el que va en la planta)
cd ~/projects/planta-monitor
arduino-cli upload -p /dev/cu.usbserial-0001 --fqbn esp32:esp32:esp32 firmware/humedad_wifi

# backend: desplegar = commitear y pushear
git add -A && git commit -m "mensaje" && git push

# ver errores del backend en producción
npx vercel logs --since 30m

# correr la app en tu Mac con la base local (no la de Supabase)
DATABASE_URL= npm run dev:lan

Archivos

  • Programa del sensor: ~/Documents/Arduino/humedad_suelo_prueba/
  • Copia del firmware original: ~/Documents/Arduino/backups/firmware_original_04b24753e728.bin
  • Proyecto completo (backend, panel, firmware): ~/projects/planta-monitor/, en un repo privado de GitHub
  • Firmware con Wi-Fi: ~/projects/planta-monitor/firmware/humedad_wifi/ (secrets.h no se sube a git)
  • Panel público: planta-monitor-pearl.vercel.app
  • Esta guía: ~/study-notes/esp32-desde-cero/index.html
Glosario

Palabras que vas a cruzarte

Voltaje (V): la "presión" eléctrica entre dos puntos.
Corriente (mA): cuánta electricidad circula.
GND: el punto de 0 V, compartido por todo el circuito.
3V3 / 5V: pines que entregan 3,3 V y 5 V.
Cortocircuito: energía unida directo a GND, sin nada en el medio. Se evita siempre.
Microcontrolador: un chip con procesador, memoria y periféricos que corre un solo programa.
Flash: memoria que guarda el programa aunque cortes la energía.
ROM: el programa de fábrica del chip, el primero que corre al arrancar.
Firmware: el programa que vive dentro de la placa.
Flashear: cargar el firmware en la placa.
GPIO: un pin que tu código puede controlar o leer.
Pin flotante: entrada sin nada conectado. Da valores al azar.
Pull-up / pull-down: resistencia que fija un pin en HIGH o LOW cuando nadie le habla.
ADC: convierte un voltaje en un número (0–4095 con 12 bits).
Atenuación: achica la señal para que el ADC pueda medir voltajes más altos.
Strapping: pines que la ROM mira al arrancar para decidir cómo arrancar.
PWM: prender y apagar rápido para simular un voltaje intermedio.
Puerto serie / UART: el canal de texto por el cable USB. Es tu consola.
Baudios: la velocidad del puerto serie. Tiene que coincidir en los dos lados.
Capacitor: dos superficies que guardan carga. Su capacidad depende de lo que tengan alrededor.
Calibrar: medir los extremos (seco y mojado) para que los números tengan sentido.
Histéresis: usar dos límites distintos para evitar avisos repetidos.
HTTP: el idioma de la web: pedidos (GET, POST) y respuestas con código de estado.
Endpoint / API: una dirección del backend que hace una tarea, como /api/lecturas.
JSON: formato de texto para mandar datos estructurados.
Token: contraseña compartida que identifica a la placa ante el backend.
Backend: el programa en un servidor que recibe, valida y guarda.
Postgres / SQL: la base de datos y el lenguaje para hablarle.
Migración: un cambio a la estructura de la base, guardado como SQL.
RLS: seguridad por fila; decide quién puede leer o escribir cada fila.
Pooler: agrupa muchas conexiones cortas a la base en unas pocas.
Deploy: publicar una versión nueva del código en la nube.
Variable de entorno: un valor (por ejemplo, un secreto) que el código lee de afuera, con process.env.
HTTPS / TLS: HTTP cifrado y con identidad verificada.
Certificado raíz: el certificado en el que se confía de antemano; firma a los demás.
NTP: protocolo para pedir la hora exacta a un servidor.
Buffer circular: lista de tamaño fijo donde lo nuevo pisa a lo más viejo cuando se llena.
Memoria RTC: memoria chica de la ESP32 que sobrevive a reinicios, pero no a cortes de luz.
Idempotencia: repetir una operación da el mismo resultado que hacerla una vez.
Watchdog: temporizador que reinicia la placa si el programa deja de dar señales de vida.
Mediana: el valor del medio de una lista ordenada; ignora los picos de ruido.