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

1465 lines
30 KiB
Markdown

# Runbook operativo del nodo ESP32 Climate Node 01
## Sustrato + BME280 + temperatura IR de hoja + MQTT MESAVAULT
## Clasificación
```yaml
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
```yaml
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.
```text
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
```text
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:
```text
- 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
```text
Placa: DFRobot ESP-WROOM-32 / ESP32
Módulo: ESP-WROOM-32
USB serie: COM4 en el PC usado durante las pruebas
```
### Sensores
```text
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
```text
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
```text
BME280 ESP32
-------------------------------------
VCC 3V3
GND GND
SDA GPIO21
SCL GPIO22
```
### MLX90614 / GY-906
```text
MLX90614 ESP32
-------------------------------------
VCC 3V3
GND GND
SDA GPIO21
SCL GPIO22
```
### Bus I2C compartido
```text
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
```text
BME280:
0x76 o 0x77
MLX90614:
0x5A
```
Lectura esperada en arranque:
```text
I2C encontrado en 0x5A
I2C encontrado en 0x76
BME280 OK
MLX90614 OK
```
## Arduino IDE
Configuración recomendada:
```text
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:
```text
Warning: Failed to communicate with the flash chip
Packet content transfer stopped
```
Solución aplicada:
```text
- 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:
```text
- 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:
```text
Usar PuTTY para ver la salida serie.
```
Configuración PuTTY:
```text
Connection type: Serial
Serial line: COM4
Speed: 115200
Data bits: 8
Stop bits: 1
Parity: None
Flow control: None
```
Regla importante:
```text
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:
```text
----- 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:
```text
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:
```text
soil_index_pct
```
No llamarlo todavía:
```text
VWC real
humedad volumétrica real
```
Lectura provisional actual:
```text
Sensor al aire:
soil_raw ≈ 3412
```
Calibración pendiente:
```text
1. Sensor en sustrato seco real
DRY_SUBSTRATE_RAW = pendiente
2. Sensor en sustrato saturado
WET_SUBSTRATE_RAW = pendiente
```
Procedimiento:
```text
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:
```cpp
int SOIL_DRY_RAW = 3412;
int SOIL_WET_RAW = 1500;
```
Dirección observada:
```text
seco → raw alto
húmedo → raw bajo
```
## Cálculos del firmware
### Humedad relativa del sustrato
```text
soil_index_pct =
(raw_actual - SOIL_DRY_RAW) / (SOIL_WET_RAW - SOIL_DRY_RAW) * 100
```
Limitado a:
```text
0 % mínimo
100 % máximo
```
### VPD del aire
Se calcula a partir de:
```text
air_temp_c
air_rh_pct
```
Variable publicada:
```text
vpd_air_kpa
```
### VPD de hoja
Se calcula usando:
```text
leaf_temp_c
air_temp_c
air_rh_pct
```
Variable publicada:
```text
vpd_leaf_kpa
```
### Delta hoja-aire
```text
leaf_air_delta_c = leaf_temp_c - air_temp_c
```
Interpretación inicial:
```text
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
```cpp
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
```cpp
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:
```text
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
```text
vertical/mesavault_lab/bench_aquaponics/aquaponics/atlas_kit_01/telemetry
```
### ESP32 Climate Node 01
```text
vertical/mesavault_lab/bench_aquaponics/aquaponics/esp32_climate_node_01/telemetry
```
### Estado del ESP32
```text
vertical/mesavault_lab/bench_aquaponics/aquaponics/esp32_climate_node_01/status
```
### Suscripción conjunta
```text
vertical/mesavault_lab/bench_aquaponics/aquaponics/+/telemetry
```
## Payload MQTT del ESP32
Ejemplo de estructura publicada:
```json
{
"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`:
```bash
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:
```text
vertical/mesavault_lab/bench_aquaponics/aquaponics/esp32_climate_node_01/telemetry {"asset":"esp32_climate_node_01",...}
```
Si no aparece:
```text
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
```text
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
```text
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:
```text
No guarda histórico.
Solo muestra mensajes mientras el panel está abierto.
```
Configuración del datasource:
```text
Name: MQTT MESAVAULT
URI: tcp://mqtt.mesavault.es:1883
Username: sensor_aquaponics_01
Password: <MQTT_PASSWORD>
```
Topic del panel:
```text
vertical/mesavault_lab/bench_aquaponics/aquaponics/esp32_climate_node_01/telemetry
```
Topic conjunto Atlas + ESP32:
```text
vertical/mesavault_lab/bench_aquaponics/aquaponics/+/telemetry
```
### Opción recomendada para histórico
```text
ESP32 / Atlas
↓ MQTT
mqtt.mesavault.es
↓ sink
PostgreSQL / TimescaleDB
Grafana
```
Variables relevantes para paneles:
```text
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:
```text
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
```text
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
```text
leaf_air_delta_c = leaf_temp_c - air_temp_c
```
Uso potencial:
```text
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.
```text
- en zona ventilada
- protegido de salpicaduras
- sin sol directo
- alejado del calor del microcontrolador
```
### MLX90614
Debe mirar a hoja real.
```text
- distancia fija
- ángulo fijo
- apuntar siempre a una hoja
- evitar que vea fondo, pared, tubo, agua o sustrato
- evitar reflejos
```
### Soil moisture
```text
- 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:
```text
Failed to communicate with the flash chip
Packet content transfer stopped
```
Solución:
```text
- 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:
```text
El LED parpadea, pero no se ve texto.
```
Solución:
```text
Usar PuTTY en COM4 a 115200 baudios.
```
### El sketch no sube
Comprobar:
```text
- PuTTY cerrado
- puerto COM correcto
- cable USB de datos
- BOOT no pulsado permanentemente
- sensores desconectados si hay dudas
```
### BME280 no detectado
Comprobar:
```text
VCC → 3V3
GND → GND
SDA → GPIO21
SCL → GPIO22
dirección 0x76 o 0x77
```
### MLX90614 no detectado
Comprobar:
```text
VCC → 3V3
GND → GND
SDA → GPIO21
SCL → GPIO22
dirección 0x5A
```
### Soil raw siempre 0 o 4095
Comprobar:
```text
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:
```text
<WIFI_PASSWORD>
<MQTT_PASSWORD>
<API_TOKEN>
```
```cpp
#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
```text
[ ] 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
```text
- 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
```text
- 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
```
```
```