# 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 = ""; 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 = ""; 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 '' \ -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: ``` 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 ``` ```cpp #include #include #include #include #include #include #include // ====================================================== // 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 ``` ``` ```