25-kits/esp32-climate-node-01.md

30 KiB

Runbook operativo del nodo ESP32 Climate Node 01

Sustrato + BME280 + temperatura IR de hoja + MQTT MESAVAULT

Clasificación

doc_type: runbook
titulo: Runbook operativo del nodo ESP32 Climate Node 01
sistema: aquaponics
servicio_afectado: esp32_climate_node_01
criticidad: pendiente_de_confirmar
estado_madurez: operativo_parcial
sensibilidad: contiene_referencias_a_credenciales_sin_secreto
owner: pendiente_de_confirmar

Metadatos RAG

doc_id: runbook-aquaponics-esp32-climate-node-01
doc_type: runbook
system: aquaponics
environment: lab
tags:
  - mesavault
  - aquaponics
  - esp32
  - mqtt
  - bme280
  - mlx90614
  - soil-moisture
  - grafana
  - timescaledb
source_date: "2026-05-03"
last_verified: "2026-05-03"
owner: pendiente_de_confirmar
sensitivity: contains_secret_placeholders
canonical: pendiente_de_confirmar
related_docs: []

Resumen ejecutivo

Este runbook documenta el nodo ESP32 Climate Node 01, usado en el banco hidropónico/acuapónico del lab MESAVAULT.

La función del nodo es medir variables de ambiente, planta y sustrato, separando estas medidas del Atlas Scientific Aquaponics Kit, que queda dedicado a química y parámetros del agua.

Atlas Scientific Aquaponics Kit
  → química y parámetros del agua:
     - pH
     - EC
     - oxígeno disuelto
     - temperatura del agua

ESP32 Climate Node 01
  → ambiente, planta y sustrato:
     - humedad relativa del sustrato
     - temperatura ambiente
     - humedad relativa ambiente
     - presión atmosférica
     - temperatura IR de hoja
     - delta hoja-aire
     - VPD aire
     - VPD hoja

El nodo publica telemetría por MQTT hacia el stack MESAVAULT.

Estado actual

ESP32 carga firmware correctamente.
PuTTY muestra salida serie.
LED parpadea.
BME280 detectado y leyendo.
MLX90614 detectado y leyendo.
Soil moisture leyendo por GPIO34.
MQTT aparentemente transmitiendo.

Pendiente:

- calibrar soil_raw en sustrato seco real
- calibrar soil_raw en sustrato saturado
- validar recepción MQTT con mosquitto_sub
- ver en Grafana en directo
- montar persistencia histórica MQTT → PostgreSQL/TimescaleDB
- fijar físicamente el MLX90614 apuntando a hoja real

Hardware

Microcontrolador

Placa: DFRobot ESP-WROOM-32 / ESP32
Módulo: ESP-WROOM-32
USB serie: COM4 en el PC usado durante las pruebas

Sensores

DFRobot Soil Moisture Sensor v1.2
  Tipo: capacitivo
  Salida: analógica
  Uso: índice relativo de humedad/dryback del sustrato

BME280
  Bus: I2C
  Uso:
    - temperatura ambiente
    - humedad relativa
    - presión atmosférica

Sensor IR de temperatura de hoja
  Modelo asumido: MLX90614 / GY-906
  Bus: I2C
  Uso:
    - temperatura superficial de hoja
    - temperatura ambiente interna del sensor IR

Cableado

DFRobot Soil Moisture v1.2

DFRobot Soil Moisture v1.2      ESP32
-------------------------------------
VCC                             3V3
GND                             GND
AOUT / Signal                   GPIO34

Se usa GPIO34 porque pertenece a ADC1 y no interfiere con WiFi.

BME280

BME280                          ESP32
-------------------------------------
VCC                             3V3
GND                             GND
SDA                             GPIO21
SCL                             GPIO22

MLX90614 / GY-906

MLX90614                        ESP32
-------------------------------------
VCC                             3V3
GND                             GND
SDA                             GPIO21
SCL                             GPIO22

Bus I2C compartido

SDA común → GPIO21
SCL común → GPIO22
Alimentación común → 3V3
GND común → GND

Todo el bus I2C debe trabajar a 3,3 V.

No alimentar los módulos I2C a 5 V si las líneas SDA / SCL quedan tiradas a 5 V.

Direcciones I2C esperadas

BME280:
  0x76 o 0x77

MLX90614:
  0x5A

Lectura esperada en arranque:

I2C encontrado en 0x5A
I2C encontrado en 0x76
BME280 OK
MLX90614 OK

Arduino IDE

Configuración recomendada:

Board: ESP32 Dev Module
Upload Speed: 115200
CPU Frequency: 240MHz
Flash Frequency: 40MHz
Flash Mode: DIO
Flash Size: 4MB
Partition Scheme: Default 4MB
Erase All Flash Before Sketch Upload: Enabled si hay problemas
Port: COM4

La selección Adafruit ESP32 Feather no funcionó correctamente para esta placa.

Durante las pruebas, con velocidades altas como 921600, aparecieron errores de escritura en flash.

Error observado:

Warning: Failed to communicate with the flash chip
Packet content transfer stopped

Solución aplicada:

- desconectar sensores
- usar ESP32 Dev Module
- bajar Upload Speed a 115200
- Flash Frequency 40MHz
- Flash Mode DIO

Puerto serie

El sketch cargaba correctamente y el LED parpadeaba, pero el Serial Monitor de Arduino IDE no mostraba texto.

Diagnóstico:

- firmware cargado correctamente
- ESP32 arrancando correctamente
- loop ejecutándose correctamente
- LED parpadeando correctamente
- salida Serial funcionando en realidad
- problema localizado en el Serial Monitor de Arduino IDE

Solución práctica:

Usar PuTTY para ver la salida serie.

Configuración PuTTY:

Connection type: Serial
Serial line: COM4
Speed: 115200
Data bits: 8
Stop bits: 1
Parity: None
Flow control: None

Regla importante:

Cerrar PuTTY antes de subir un nuevo sketch desde Arduino IDE.

PuTTY bloquea el puerto COM mientras está abierto.

Lecturas validadas

Ejemplo real con el sensor de humedad al aire:

----- LECTURA -----
soil_raw=3412 soil_voltage=2.750
air_temp_c=23.24 air_rh_pct=71.87 pressure_hpa=999.80
leaf_temp_c=21.51 ir_ambient_c=21.63

Interpretación:

soil_raw=3412
  Lectura al aire.
  Se toma como referencia provisional de seco, pero no como calibración definitiva.

soil_voltage=2.750 V
  Coherente con salida analógica del sensor.

air_temp_c=23.24
air_rh_pct=71.87
pressure_hpa=999.80
  Lectura razonable del BME280.

leaf_temp_c=21.51
ir_ambient_c=21.63
  Lectura coherente del MLX90614, aunque todavía depende de a qué apunte el sensor IR.

Calibración del sensor de humedad de sustrato

El DFRobot Soil Moisture v1.2 no debe tratarse como sensor profesional de VWC absoluto.

Se usará como índice relativo de humedad/dryback.

Nombre recomendado para la variable:

soil_index_pct

No llamarlo todavía:

VWC real
humedad volumétrica real

Lectura provisional actual:

Sensor al aire:
soil_raw ≈ 3412

Calibración pendiente:

1. Sensor en sustrato seco real
   DRY_SUBSTRATE_RAW = pendiente

2. Sensor en sustrato saturado
   WET_SUBSTRATE_RAW = pendiente

Procedimiento:

1. Insertar el sensor en el sustrato seco con la profundidad real de uso.
2. Esperar 30-60 s.
3. Anotar soil_raw.

4. Regar o saturar el sustrato.
5. Esperar 1-2 min.
6. Anotar soil_raw.

7. Sustituir los valores en el firmware:
   SOIL_DRY_RAW
   SOIL_WET_RAW

Valores provisionales usados en el sketch:

int SOIL_DRY_RAW = 3412;
int SOIL_WET_RAW = 1500;

Dirección observada:

seco   → raw alto
húmedo → raw bajo

Cálculos del firmware

Humedad relativa del sustrato

soil_index_pct =
(raw_actual - SOIL_DRY_RAW) / (SOIL_WET_RAW - SOIL_DRY_RAW) * 100

Limitado a:

0 % mínimo
100 % máximo

VPD del aire

Se calcula a partir de:

air_temp_c
air_rh_pct

Variable publicada:

vpd_air_kpa

VPD de hoja

Se calcula usando:

leaf_temp_c
air_temp_c
air_rh_pct

Variable publicada:

vpd_leaf_kpa

Delta hoja-aire

leaf_air_delta_c = leaf_temp_c - air_temp_c

Interpretación inicial:

leaf_air_delta_c negativo:
  la hoja está más fría que el aire, posible transpiración activa.

leaf_air_delta_c cercano a cero:
  hoja y aire muy parecidos.

leaf_air_delta_c positivo:
  posible estrés hídrico, mala transpiración, radiación/fondo caliente o montaje mal orientado.

No interpretar esta variable sin fijar físicamente el sensor IR apuntando siempre a hoja real.

MQTT

Configuración Atlas Scientific Aquaponics Kit

const char* mqtt_host = "mqtt.mesavault.es";
const uint16_t mqtt_port = 1883;

const char* mqtt_user = "sensor_aquaponics_01";
const char* mqtt_pass = "<MQTT_PASSWORD>";

const char* mqtt_client_id = "atlas_kit_01";

const char* mqtt_topic =
  "vertical/mesavault_lab/bench_aquaponics/aquaponics/atlas_kit_01/telemetry";

Configuración ESP32 Climate Node 01

const char* mqtt_host = "mqtt.mesavault.es";
const uint16_t mqtt_port = 1883;

const char* mqtt_user = "sensor_aquaponics_01";
const char* mqtt_pass = "<MQTT_PASSWORD>";

const char* mqtt_client_id = "esp32_climate_node_01";

const char* mqtt_topic =
  "vertical/mesavault_lab/bench_aquaponics/aquaponics/esp32_climate_node_01/telemetry";

const char* mqtt_status_topic =
  "vertical/mesavault_lab/bench_aquaponics/aquaponics/esp32_climate_node_01/status";

Regla importante:

No reutilizar el mismo mqtt_client_id del Atlas.

Si dos clientes MQTT usan el mismo client_id, uno expulsa al otro.

Topics MQTT

Atlas Scientific Aquaponics Kit

vertical/mesavault_lab/bench_aquaponics/aquaponics/atlas_kit_01/telemetry

ESP32 Climate Node 01

vertical/mesavault_lab/bench_aquaponics/aquaponics/esp32_climate_node_01/telemetry

Estado del ESP32

vertical/mesavault_lab/bench_aquaponics/aquaponics/esp32_climate_node_01/status

Suscripción conjunta

vertical/mesavault_lab/bench_aquaponics/aquaponics/+/telemetry

Payload MQTT del ESP32

Ejemplo de estructura publicada:

{
  "asset": "esp32_climate_node_01",
  "site": "bench_aquaponics",
  "vertical": "aquaponics",
  "node_type": "esp32_soil_bme280_mlx90614",
  "fw": "0.1.0",
  "uptime_ms": 123456,
  "ts": "2026-05-03T12:00:00Z",
  "wifi_rssi_dbm": -54,
  "bme_ok": true,
  "mlx_ok": true,
  "soil_raw": 3412,
  "soil_voltage": 2.750,
  "soil_index_pct": 0.0,
  "soil_dry_raw": 3412,
  "soil_wet_raw": 1500,
  "air_temp_c": 23.24,
  "air_rh_pct": 71.87,
  "air_pressure_hpa": 999.80,
  "leaf_temp_c": 21.51,
  "ir_ambient_c": 21.63,
  "leaf_air_delta_c": -1.73,
  "vpd_air_kpa": 0.790,
  "vpd_leaf_kpa": 0.530
}

Verificación MQTT

Desde un equipo con mosquitto_sub:

mosquitto_sub -h mqtt.mesavault.es \
  -p 1883 \
  -u sensor_aquaponics_01 \
  -P '<MQTT_PASSWORD>' \
  -t 'vertical/mesavault_lab/bench_aquaponics/aquaponics/+/telemetry' \
  -v

Resultado esperado:

vertical/mesavault_lab/bench_aquaponics/aquaponics/esp32_climate_node_01/telemetry {"asset":"esp32_climate_node_01",...}

Si no aparece:

1. revisar WiFi del ESP32
2. revisar usuario/clave MQTT
3. revisar ACL del broker
4. revisar topic
5. revisar si el ESP32 imprime "MQTT OK" por PuTTY

ACL MQTT recomendada

Opción lab

user sensor_aquaponics_01
topic write vertical/mesavault_lab/bench_aquaponics/aquaponics/+/telemetry
topic write vertical/mesavault_lab/bench_aquaponics/aquaponics/+/status

Opción restrictiva

user sensor_aquaponics_01
topic write vertical/mesavault_lab/bench_aquaponics/aquaponics/atlas_kit_01/telemetry
topic write vertical/mesavault_lab/bench_aquaponics/aquaponics/esp32_climate_node_01/telemetry
topic write vertical/mesavault_lab/bench_aquaponics/aquaponics/esp32_climate_node_01/status

Grafana

Grafana no ve MQTT automáticamente.

Opción rápida: MQTT datasource

Sirve para ver datos en vivo.

Limitación:

No guarda histórico.
Solo muestra mensajes mientras el panel está abierto.

Configuración del datasource:

Name: MQTT MESAVAULT
URI: tcp://mqtt.mesavault.es:1883
Username: sensor_aquaponics_01
Password: <MQTT_PASSWORD>

Topic del panel:

vertical/mesavault_lab/bench_aquaponics/aquaponics/esp32_climate_node_01/telemetry

Topic conjunto Atlas + ESP32:

vertical/mesavault_lab/bench_aquaponics/aquaponics/+/telemetry

Opción recomendada para histórico

ESP32 / Atlas
  ↓ MQTT
mqtt.mesavault.es
  ↓ sink
PostgreSQL / TimescaleDB
  ↓
Grafana

Variables relevantes para paneles:

soil_index_pct
soil_raw
soil_voltage
air_temp_c
air_rh_pct
air_pressure_hpa
leaf_temp_c
ir_ambient_c
leaf_air_delta_c
vpd_air_kpa
vpd_leaf_kpa
wifi_rssi_dbm
bme_ok
mlx_ok

Paneles iniciales recomendados:

Panel 1: Sustrato
  - soil_index_pct
  - soil_raw
  - soil_voltage

Panel 2: Clima
  - air_temp_c
  - air_rh_pct
  - vpd_air_kpa

Panel 3: Hoja
  - leaf_temp_c
  - leaf_air_delta_c
  - vpd_leaf_kpa

Panel 4: Estado del nodo
  - wifi_rssi_dbm
  - bme_ok
  - mlx_ok

Reglas de interpretación

Sustrato

soil_raw alto:
  tendencia seca

soil_raw bajo:
  tendencia húmeda

soil_index_pct:
  índice relativo calibrado entre seco y saturado

No interpretar como VWC real.

Hoja

leaf_air_delta_c = leaf_temp_c - air_temp_c

Uso potencial:

VPD alto + soil_index_pct bajo + leaf_air_delta_c subiendo
  → posible riesgo de estrés hídrico

riego ejecutado + soil_index_pct no sube
  → posible gotero obstruido, mala distribución o sensor mal colocado

soil_index_pct alto + drenaje frecuente
  → posible exceso de riego

VPD bajo + humedad alta + poca diferencia hoja-aire
  → ambiente poco demandante, posible riesgo por exceso de humedad

Montaje físico recomendado

BME280

No colocarlo pegado al ESP32 ni dentro de una caja cerrada.

- en zona ventilada
- protegido de salpicaduras
- sin sol directo
- alejado del calor del microcontrolador

MLX90614

Debe mirar a hoja real.

- distancia fija
- ángulo fijo
- apuntar siempre a una hoja
- evitar que vea fondo, pared, tubo, agua o sustrato
- evitar reflejos

Soil moisture

- insertar siempre a la misma profundidad
- evitar moverlo continuamente
- no sumergir electrónica ni conector
- usarlo como tendencia relativa
- recalibrar si se cambia de sustrato

Problemas conocidos y soluciones

Error al subir firmware

Error:

Failed to communicate with the flash chip
Packet content transfer stopped

Solución:

- seleccionar ESP32 Dev Module
- Upload Speed 115200
- Flash Frequency 40MHz
- Flash Mode DIO
- desconectar sensores
- cerrar PuTTY o cualquier monitor serie antes de subir

No aparece texto en Arduino Serial Monitor

Diagnóstico:

El LED parpadea, pero no se ve texto.

Solución:

Usar PuTTY en COM4 a 115200 baudios.

El sketch no sube

Comprobar:

- PuTTY cerrado
- puerto COM correcto
- cable USB de datos
- BOOT no pulsado permanentemente
- sensores desconectados si hay dudas

BME280 no detectado

Comprobar:

VCC → 3V3
GND → GND
SDA → GPIO21
SCL → GPIO22
dirección 0x76 o 0x77

MLX90614 no detectado

Comprobar:

VCC → 3V3
GND → GND
SDA → GPIO21
SCL → GPIO22
dirección 0x5A

Soil raw siempre 0 o 4095

Comprobar:

VCC → 3V3
GND → GND
AOUT → GPIO34
sensor no alimentado a 5V si la señal excede 3,3V

Firmware actual

Pegar aquí el firmware actual del nodo esp32_climate_node_01.

No incluir secretos reales. Sustituir contraseñas, tokens o claves por marcadores como:

<WIFI_PASSWORD>
<MQTT_PASSWORD>
<API_TOKEN>

#include <WiFi.h>
#include <Wire.h>
#include <PubSubClient.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
#include <Adafruit_MLX90614.h>
#include <time.h>

// ======================================================
// WIFI
// ======================================================

const char* wifi_ssid = "TP-Link_AF90";
const char* wifi_pass = "84604422";

// ======================================================
// MQTT MESAVAULT
// ======================================================

const char* mqtt_host = "mqtt.mesavault.es";
const uint16_t mqtt_port = 1883;

const char* mqtt_user = "sensor_climate_node_01";
const char* mqtt_pass = "06Xq9vll5RtV";

const char* mqtt_client_id = "esp32_climate_node_01";

const char* mqtt_topic =
"vertical/mesavault_lab/bench_aquaponics/climate/esp32_climate_node_01/telemetry";

const char* mqtt_state_topic =
"vertical/mesavault_lab/bench_aquaponics/climate/esp32_climate_node_01/state";

// ======================================================
// HARDWARE
// ======================================================

#define I2C_SDA 21
#define I2C_SCL 22

#define SOIL_PIN 34
#define LED_PIN 2

// ======================================================
// CALIBRACIÓN HUMEDAD SUSTRATO
// ======================================================
//
// Ahora mismo has medido al aire:
// soil_raw ≈ 3412
//
// Esto NO es todavía el valor seco real del sustrato.
// Cuando calibres en sustrato seco y saturado, sustituye estos valores.
//
// Dirección observada:
// seco -> raw alto
// húmedo -> raw bajo

int SOIL_DRY_RAW = 3412; // provisional: lectura al aire
int SOIL_WET_RAW = 1500; // provisional: ajustar con sustrato saturado real

// ======================================================
// INTERVALOS
// ======================================================

const unsigned long PUBLISH_INTERVAL_MS = 10000;
const unsigned long MQTT_RETRY_INTERVAL_MS = 5000;
const unsigned long WIFI_RETRY_INTERVAL_MS = 10000;

// ======================================================
// OBJETOS
// ======================================================

WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);

Adafruit_BME280 bme;
Adafruit_MLX90614 mlx = Adafruit_MLX90614();

bool bme_ok = false;
bool mlx_ok = false;

unsigned long last_publish_ms = 0;
unsigned long last_mqtt_retry_ms = 0;
unsigned long last_wifi_retry_ms = 0;

// ======================================================
// FUNCIONES VPD
// ======================================================

float saturationVaporPressureKpa(float tempC) {
return 0.6108 * exp((17.27 * tempC) / (tempC + 237.3));
}

float calculateAirVpdKpa(float airTempC, float rhPct) {
float svp = saturationVaporPressureKpa(airTempC);
float avp = svp * (rhPct / 100.0);
float vpd = svp - avp;
if (vpd < 0) vpd = 0;
return vpd;
}

float calculateLeafVpdKpa(float leafTempC, float airTempC, float rhPct) {
float svpLeaf = saturationVaporPressureKpa(leafTempC);
float svpAir = saturationVaporPressureKpa(airTempC);
float avpAir = svpAir * (rhPct / 100.0);

float vpdLeaf = svpLeaf - avpAir;
if (vpdLeaf < 0) vpdLeaf = 0;

return vpdLeaf;
}

// ======================================================
// HUMEDAD SUSTRATO
// ======================================================

int readSoilRawAverage() {
const int samples = 30;
long acc = 0;

for (int i = 0; i < samples; i++) {
acc += analogRead(SOIL_PIN);
delay(4);
}

return acc / samples;
}

float soilIndexPct(int raw) {
if (SOIL_DRY_RAW == SOIL_WET_RAW) {
return 0.0;
}

float pct =
((float)(raw - SOIL_DRY_RAW) / (float)(SOIL_WET_RAW - SOIL_DRY_RAW)) * 100.0;

if (pct < 0) pct = 0;
if (pct > 100) pct = 100;

return pct;
}

// ======================================================
// TIEMPO
// ======================================================

String isoTimestampUtc() {
time_t now = time(nullptr);

if (now < 1700000000) {
return "";
}

struct tm timeinfo;
gmtime_r(&now, &timeinfo);

char buffer[25];
strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &timeinfo);

return String(buffer);
}

// ======================================================
// I2C
// ======================================================

void scanI2C() {
Serial.println("Escaneando I2C...");

int count = 0;

for (byte addr = 1; addr < 127; addr++) {
Wire.beginTransmission(addr);
byte error = Wire.endTransmission();

if (error == 0) {
  Serial.print("I2C encontrado en 0x");
  if (addr < 16) Serial.print("0");
  Serial.println(addr, HEX);
  count++;
}
}

Serial.print("Total I2C encontrados: ");
Serial.println(count);
}

// ======================================================
// WIFI
// ======================================================

void connectWiFiIfNeeded() {
if (WiFi.status() == WL_CONNECTED) {
return;
}

unsigned long now = millis();

if (now - last_wifi_retry_ms < WIFI_RETRY_INTERVAL_MS) {
return;
}

last_wifi_retry_ms = now;

Serial.print("Conectando WiFi a ");
Serial.println(wifi_ssid);

WiFi.mode(WIFI_STA);
WiFi.begin(wifi_ssid, wifi_pass);

unsigned long start = millis();

while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) {
delay(500);
Serial.print(".");
}

Serial.println();

if (WiFi.status() == WL_CONNECTED) {
Serial.print("WiFi OK. IP: ");
Serial.println(WiFi.localIP());

configTime(0, 0, "pool.ntp.org", "time.nist.gov");
} else {
Serial.println("WiFi KO.");
}
}

// ======================================================
// MQTT
// ======================================================

void connectMQTTIfNeeded() {
if (mqtt.connected()) {
return;
}

if (WiFi.status() != WL_CONNECTED) {
return;
}

unsigned long now = millis();

if (now - last_mqtt_retry_ms < MQTT_RETRY_INTERVAL_MS) {
return;
}

last_mqtt_retry_ms = now;

Serial.print("Conectando MQTT a ");
Serial.print(mqtt_host);
Serial.print(":");
Serial.println(mqtt_port);

mqtt.setServer(mqtt_host, mqtt_port);
mqtt.setBufferSize(1200);
mqtt.setKeepAlive(30);
mqtt.setSocketTimeout(10);

bool ok = mqtt.connect(
mqtt_client_id,
mqtt_user,
mqtt_pass,
mqtt_state_topic,
0,
true,
"offline"
);

if (ok) {
Serial.println("MQTT OK.");
mqtt.publish(mqtt_state_topic, "online", true);
} else {
Serial.print("MQTT KO. state=");
Serial.println(mqtt.state());
}
}

// ======================================================
// SETUP
// ======================================================

void setup() {
pinMode(LED_PIN, OUTPUT);

Serial.begin(115200);
delay(3000);

Serial.println();
Serial.println("");
Serial.println("MESAVAULT ESP32 CLIMATE NODE 01");
Serial.println("Soil + BME280 + MLX90614 + MQTT");
Serial.println("");

analogReadResolution(12);
analogSetPinAttenuation(SOIL_PIN, ADC_11db);

Wire.begin(I2C_SDA, I2C_SCL);
delay(500);

scanI2C();

bme_ok = bme.begin(0x76, &Wire);
if (!bme_ok) {
bme_ok = bme.begin(0x77, &Wire);
}

if (bme_ok) {
Serial.println("BME280 OK.");
} else {
Serial.println("BME280 NO DETECTADO.");
}

mlx_ok = mlx.begin(0x5A, &Wire);

if (mlx_ok) {
Serial.println("MLX90614 OK.");
} else {
Serial.println("MLX90614 NO DETECTADO.");
}

connectWiFiIfNeeded();
connectMQTTIfNeeded();

Serial.println("Setup terminado.");
}

// ======================================================
// LOOP
// ======================================================

void loop() {
digitalWrite(LED_PIN, !digitalRead(LED_PIN));

connectWiFiIfNeeded();
connectMQTTIfNeeded();

if (mqtt.connected()) {
mqtt.loop();
}

unsigned long now = millis();

if (now - last_publish_ms < PUBLISH_INTERVAL_MS) {
delay(50);
return;
}

last_publish_ms = now;

// -----------------------------
// Lectura humedad sustrato
// -----------------------------

int soil_raw = readSoilRawAverage();
float soil_voltage = soil_raw * 3.3 / 4095.0;
float soil_index_pct = soilIndexPct(soil_raw);

// -----------------------------
// Lectura BME280
// -----------------------------

float air_temp_c = NAN;
float air_rh_pct = NAN;
float air_pressure_hpa = NAN;

if (bme_ok) {
air_temp_c = bme.readTemperature();
air_rh_pct = bme.readHumidity();
air_pressure_hpa = bme.readPressure() / 100.0;
}

// -----------------------------
// Lectura MLX90614
// -----------------------------

float leaf_temp_c = NAN;
float ir_ambient_c = NAN;

if (mlx_ok) {
leaf_temp_c = mlx.readObjectTempC();
ir_ambient_c = mlx.readAmbientTempC();
}

// -----------------------------
// Cálculos
// -----------------------------

float vpd_air_kpa = NAN;
float vpd_leaf_kpa = NAN;
float leaf_air_delta_c = NAN;

if (bme_ok && !isnan(air_temp_c) && !isnan(air_rh_pct)) {
vpd_air_kpa = calculateAirVpdKpa(air_temp_c, air_rh_pct);
}

if (bme_ok && mlx_ok && !isnan(leaf_temp_c) && !isnan(air_temp_c) && !isnan(air_rh_pct)) {
leaf_air_delta_c = leaf_temp_c - air_temp_c;
vpd_leaf_kpa = calculateLeafVpdKpa(leaf_temp_c, air_temp_c, air_rh_pct);
}

String ts = isoTimestampUtc();

// -----------------------------
// JSON MQTT
// -----------------------------

String payload;
payload.reserve(1300);

payload += "{";

payload += ""tenant":"mesavault_lab",";
payload += ""site":"bench_aquaponics",";
payload += ""product":"climate",";
payload += ""asset":"esp32_climate_node_01",";
payload += ""node_type":"esp32_soil_bme280_mlx90614",";
payload += ""fw":"0.1.0",";
payload += ""uptime_ms":";
payload += String(millis());
payload += ",";

payload += ""ts":"";
payload += ts;
payload += "",";

payload += ""wifi_rssi_dbm":";
payload += String(WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : 0);
payload += ",";

payload += ""bme_ok":";
payload += bme_ok ? "true" : "false";
payload += ",";

payload += ""mlx_ok":";
payload += mlx_ok ? "true" : "false";
payload += ",";

payload += ""soil_raw":";
payload += String(soil_raw);
payload += ",";

payload += ""soil_voltage":";
payload += String(soil_voltage, 3);
payload += ",";

payload += ""soil_index_pct":";
payload += String(soil_index_pct, 1);
payload += ",";

payload += ""soil_dry_raw":";
payload += String(SOIL_DRY_RAW);
payload += ",";

payload += ""soil_wet_raw":";
payload += String(SOIL_WET_RAW);
payload += ",";

payload += ""air_temp_c":";
if (isnan(air_temp_c)) payload += "null"; else payload += String(air_temp_c, 2);
payload += ",";

payload += ""air_humidity_pct":";
if (isnan(air_rh_pct)) payload += "null"; else payload += String(air_rh_pct, 2);
payload += ",";

payload += ""pressure_hpa":";
if (isnan(air_pressure_hpa)) payload += "null"; else payload += String(air_pressure_hpa, 2);
payload += ",";

payload += ""leaf_temp_c":";
if (isnan(leaf_temp_c)) payload += "null"; else payload += String(leaf_temp_c, 2);
payload += ",";

payload += ""mlx_ambient_c":";
if (isnan(ir_ambient_c)) payload += "null"; else payload += String(ir_ambient_c, 2);
payload += ",";

payload += ""leaf_air_delta_c":";
if (isnan(leaf_air_delta_c)) payload += "null"; else payload += String(leaf_air_delta_c, 2);
payload += ",";

payload += ""vpd_kpa":";
if (isnan(vpd_air_kpa)) payload += "null"; else payload += String(vpd_air_kpa, 3);
payload += ",";

payload += ""vpd_leaf_kpa":";
if (isnan(vpd_leaf_kpa)) payload += "null"; else payload += String(vpd_leaf_kpa, 3);

payload += "}";

// -----------------------------
// Debug por Serial / PuTTY
// -----------------------------

Serial.println();
Serial.println("----- LECTURA -----");

Serial.print("soil_raw=");
Serial.print(soil_raw);
Serial.print(" soil_voltage=");
Serial.print(soil_voltage, 3);
Serial.print(" soil_index_pct=");
Serial.println(soil_index_pct, 1);

if (bme_ok) {
Serial.print("air_temp_c=");
Serial.print(air_temp_c, 2);
Serial.print(" air_rh_pct=");
Serial.print(air_rh_pct, 2);
Serial.print(" pressure_hpa=");
Serial.println(air_pressure_hpa, 2);
} else {
Serial.println("BME280 sin lectura.");
}

if (mlx_ok) {
Serial.print("leaf_temp_c=");
Serial.print(leaf_temp_c, 2);
Serial.print(" ir_ambient_c=");
Serial.print(ir_ambient_c, 2);
Serial.print(" leaf_air_delta_c=");
Serial.println(leaf_air_delta_c, 2);
} else {
Serial.println("MLX90614 sin lectura.");
}

Serial.print("vpd_air_kpa=");
if (isnan(vpd_air_kpa)) Serial.print("null"); else Serial.print(vpd_air_kpa, 3);

Serial.print(" vpd_leaf_kpa=");
if (isnan(vpd_leaf_kpa)) Serial.println("null"); else Serial.println(vpd_leaf_kpa, 3);

Serial.println("Payload:");
Serial.println(payload);

// -----------------------------
// Publicación MQTT
// -----------------------------

if (mqtt.connected()) {
bool ok = mqtt.publish(mqtt_topic, payload.c_str(), false);

if (ok) {
  Serial.print("MQTT publicado en: ");
  Serial.println(mqtt_topic);
} else {
  Serial.println("MQTT publish KO.");
}
} else {
Serial.println("MQTT no conectado. No se publica.");
}

delay(50);
}

Checklist operativo

[ ] Firmware cargado con Board = ESP32 Dev Module
[ ] Upload Speed = 115200
[ ] Flash Frequency = 40MHz
[ ] Flash Mode = DIO
[ ] PuTTY cerrado antes de subir firmware
[ ] PuTTY abierto después de subir firmware
[ ] BME280 detectado en 0x76 o 0x77
[ ] MLX90614 detectado en 0x5A
[ ] Soil moisture leyendo por GPIO34
[ ] soil_raw no queda fijo en 0 o 4095
[ ] MQTT usa client_id esp32_climate_node_01
[ ] MQTT no reutiliza client_id atlas_kit_01
[ ] Telemetría visible con mosquitto_sub
[ ] Grafana muestra datos en vivo
[ ] Contraseña MQTT real almacenada fuera de la documentación
[ ] SOIL_DRY_RAW calibrado en sustrato seco real
[ ] SOIL_WET_RAW calibrado en sustrato saturado
[ ] MLX90614 fijado apuntando a hoja real

Huecos y validaciones pendientes

- no se incluye todavía el firmware actual completo
- no se especifica versión exacta de librerías Arduino usadas
- no se especifica modelo exacto confirmado del sensor IR; se asume MLX90614 / GY-906
- no se documenta configuración WiFi
- no se documenta contraseña MQTT real, correctamente omitida
- no se confirma ACL realmente aplicada en el broker MQTT
- no se confirma recepción MQTT con mosquitto_sub
- no se confirma visualización real en Grafana
- no se documenta sink MQTT → PostgreSQL / TimescaleDB
- no se documenta dashboard final de Grafana
- no se documenta ubicación física definitiva del nodo
- no se documenta distancia ni ángulo final del MLX90614
- no se documenta calibración real de SOIL_DRY_RAW
- no se documenta calibración real de SOIL_WET_RAW
- no se documenta procedimiento de backup del firmware
- no se documenta rollback formal a una versión anterior del firmware

Acciones recomendadas

- guardar contraseña MQTT real en Vaultwarden
- pegar el firmware actual saneado en la sección "Firmware actual"
- versionar el firmware como archivo separado si se desea mantener trazabilidad de código
- crear ADR sobre separación Atlas / ESP32 por responsabilidad de sensorización
- crear documento de arquitectura MQTT → PostgreSQL / TimescaleDB → Grafana
- crear runbook específico de calibración de sensores de sustrato
- crear runbook de troubleshooting MQTT MESAVAULT