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