diff --git a/atlas-scientific-wifi-aquaponics-kit.md b/atlas-scientific-wifi-aquaponics-kit.md new file mode 100644 index 0000000..c6d9695 --- /dev/null +++ b/atlas-scientific-wifi-aquaponics-kit.md @@ -0,0 +1,2426 @@ +REPOSITORIO + +`mesavault-knowledge` + +RUTA_FINAL + +`runbooks/hydro-aquaponics-climate/atlas-scientific-wifi-aquaponics-kit.md` + +CONTENIDO + +# Runbook operativo: Atlas Scientific WiFi Aquaponics Kit + +```yaml +doc_id: runbook-hydro-aquaponics-climate-atlas-kit-01 +doc_type: runbook +system: hydro-aquaponics-climate +environment: lab +asset: atlas_kit_01 +tags: + - hydro + - aquaponics + - atlas-scientific + - mqtt + - i2c + - ezo + - bme280 + - mlx90614 + - grafana + - postgresql +source_date: null +last_verified: null +owner: pendiente_de_confirmar +sensitivity: pendiente_de_confirmar +canonical: pendiente_de_confirmar +related_docs: [] +``` + +## Resumen ejecutivo + +El `Atlas Scientific WiFi Aquaponics Kit` se usa como nodo principal de medición para la vertical `Hydro / Aquaponics Climate`. + +La configuración estable actual mide parámetros de agua, aire y planta, y publica telemetría por MQTT hacia el stack MESAVAULT. + +ThingSpeak queda eliminado. El kit publica directamente en MESAVAULT. + +Flujo operativo: + +```text +Aquaponics Kit + ↓ WiFi +mqtt.mesavault.es:1883 + ↓ +OVH + ↓ bridge MQTT por Tailscale +platform-40 + ↓ +PostgreSQL + ↓ +Grafana +``` + +## Estado actual + +### Sensores activos + +```text +- pH EZO +- EC EZO +- DO EZO +- RTD EZO +- BME280 +- MLX90614 IR +``` + +### Sensores retirados + +```text +- CO2 EZO +- HUM EZO del kit Atlas +``` + +Motivo: + +```text +El bus I2C se degrada cuando se conectan demasiados dispositivos. +El CO2 y/o HUM provocaban fallos de detección, lecturas NaN, direcciones raras y pérdida del MLX90614. +``` + +Configuración válida actual: + +```text +pH + DO + RTD + EC + BME280 + MLX90614 +``` + +Configuración no recomendada: + +```text +pH + DO + RTD + EC + HUM + CO2 + BME280 + MLX90614 +``` + +## Firmware + +El firmware completo debe pegarse exclusivamente entre los marcadores siguientes. + +No incluir credenciales reales, tokens, claves privadas, contraseñas WiFi ni secretos MQTT en este archivo. Mantener placeholders y guardar secretos reales en Vaultwarden. + + + +```cpp +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ====================================================== +// WIFI + MQTT MESAVAULT +// ====================================================== + +WiFiClient wifiClient; +PubSubClient mqtt(wifiClient); + +// ---------- WiFi ---------- +const char* ssid = "..."; +const char* pass = "PASSWORD_EN_VAULTWARDEN"; + +// ---------- MQTT MESAVAULT ---------- +const char* mqtt_host = "mqtt.mesavault.es"; +const uint16_t mqtt_port = 1883; + +const char* mqtt_user = "sensor_aquaponics_01"; +const char* mqtt_pass = "PASSWORD_EN_VAULTWARDEN"; + +const char* mqtt_client_id = "atlas_kit_01"; + +const char* mqtt_topic = + "vertical/mesavault_lab/bench_aquaponics/aquaponics/atlas_kit_01/telemetry"; + +// MQTT activo desde arranque +const bool MQTT_AUTOSTART = true; + +// ====================================================== +// TIMING +// ====================================================== + +// Espera de respuesta EZO +const unsigned long EZO_RESPONSE_DELAY_MS = 1800; + +// Espera específica para RTD +const unsigned long RTD_RESPONSE_DELAY_MS = 2000; + +// Pausa entre sensores +const unsigned long SENSOR_GAP_MS = 300; + +// Pausa entre compensaciones +const unsigned long COMPENSATION_GAP_MS = 250; + +// Intervalo entre ciclos completos +unsigned long sensor_cycle_interval_ms = 12000; + +// Intervalo mínimo entre publicaciones MQTT +const unsigned long mqtt_publish_interval_ms = 5000; + +unsigned long last_sensor_cycle_ms = 0; +unsigned long last_mqtt_publish_ms = 0; +unsigned long last_wifi_check_ms = 0; +unsigned long last_mqtt_check_ms = 0; + +bool first_sensor_cycle = true; + +// ====================================================== +// ATLAS EZO BOARDS +// ====================================================== + +Ezo_board PH = Ezo_board(99, "PH"); // 0x63 +Ezo_board DO = Ezo_board(97, "DO"); // 0x61 +Ezo_board RTD = Ezo_board(102, "RTD"); // 0x66 +Ezo_board EC = Ezo_board(100, "EC"); // 0x64 +Ezo_board PMP = Ezo_board(103, "PMP"); // 0x67, no usado + +bool ph_ok = false; +bool do_ok = false; +bool rtd_ok = false; +bool ec_ok = false; + +// ====================================================== +// BME280 + MLX90614 +// ====================================================== + +#define BME_ADDR 0x76 +#define MLX_ADDR 0x5A + +Adafruit_BME280 bme; +Adafruit_MLX90614 mlx = Adafruit_MLX90614(); + +bool bme_ok = false; +bool mlx_ok = false; + +float bme_air_temp = NAN; +float bme_air_hum = NAN; +float bme_pressure_hpa = NAN; +float bme_vpd_kpa = NAN; + +float mlx_leaf_temp_c = NAN; +float mlx_ambient_c = NAN; +float leaf_air_delta_c = NAN; + +// ====================================================== +// ÚLTIMAS LECTURAS NORMALIZADAS +// ====================================================== + +float ph_value = NAN; +float do_value = NAN; +float rtd_value = NAN; +float ec_value = NAN; + +// ====================================================== +// DEVICE LIST PARA COMANDOS EZO +// ====================================================== + +Ezo_board device_list[] = { + PH, + DO, + RTD, + EC, + PMP +}; + +Ezo_board* default_board = &device_list[0]; +const uint8_t device_list_len = sizeof(device_list) / sizeof(device_list[0]); + +// ====================================================== +// ENABLE PINS +// ====================================================== + +// ------ Board version 1.7 ------ +const int EN_PH = 13; +const int EN_DO = 12; +const int EN_RTD = 33; +const int EN_EC = 27; +const int EN_HUM = 32; +const int EN_CO2 = 15; + +// ------ Board version 1.8 ------ +// const int EN_PH = 12; +// const int EN_DO = 27; +// const int EN_RTD = 15; +// const int EN_EC = 33; +// const int EN_HUM = 14; +// const int EN_CO2 = 32; + +// ====================================================== +// PUMP CONFIGURATION +// ====================================================== +// Bomba desactivada en este sketch. + +#define PUMP_BOARD PMP +#define PUMP_DOSE -0.5 +#define EZO_BOARD EC +#define IS_GREATER_THAN true +#define COMPARISON_VALUE 1000 + +// ====================================================== +// GLOBAL STATE +// ====================================================== + +float k_val = 0; + +bool polling = true; +bool send_to_stack = MQTT_AUTOSTART; + +// ====================================================== +// FORWARD DECLARATIONS +// ====================================================== + +bool wifi_isconnected(); +void reconnect_wifi(); +void reconnect_mqtt(); +void service_network(); +void wait_with_network(unsigned long wait_ms); + +bool i2c_device_present(byte address); +uint8_t read_i2c_reg8(byte address, byte reg); +void detect_i2c_devices(); +void refresh_i2c_state(); +void scan_i2c(); + +String jsonFloat(float value, uint8_t decimals); +String build_telemetry_payload(); +void mqtt_send_if_due(); + +void start_datalogging(); +void run_sensor_cycle(); +bool read_rtd_and_compensate(); +void read_ezo_sensor(Ezo_board &sensor, bool sensor_ok, float &target_value); +void read_bme280(); +void read_mlx90614(); + +float calc_vpd_kpa(float temp_c, float rh_percent); + +bool process_coms(const String &string_buffer); +void get_ec_k_value(); +void print_help(); + +void pump_function(Ezo_board &pump, Ezo_board &sensor, float value, float dose, bool greater_than); + +// ====================================================== +// WIFI +// ====================================================== + +bool wifi_isconnected() { + return (WiFi.status() == WL_CONNECTED); +} + +void reconnect_wifi() { + if (!wifi_isconnected()) { + WiFi.begin(ssid, pass); + Serial.println("connecting to wifi"); + } +} + +// ====================================================== +// MQTT +// ====================================================== + +void reconnect_mqtt() { + if (!wifi_isconnected()) { + return; + } + + if (mqtt.connected()) { + return; + } + + Serial.print("connecting to MQTT "); + + if (mqtt.connect(mqtt_client_id, mqtt_user, mqtt_pass)) { + Serial.println("connected"); + } else { + Serial.print("failed, rc="); + Serial.println(mqtt.state()); + } +} + +void service_network() { + unsigned long now = millis(); + + if ((now - last_wifi_check_ms) > 10000) { + last_wifi_check_ms = now; + reconnect_wifi(); + } + + if ((now - last_mqtt_check_ms) > 5000) { + last_mqtt_check_ms = now; + reconnect_mqtt(); + } + + if (mqtt.connected()) { + mqtt.loop(); + } +} + +void wait_with_network(unsigned long wait_ms) { + unsigned long start = millis(); + + while ((millis() - start) < wait_ms) { + service_network(); + delay(25); + } +} + +// ====================================================== +// JSON + MQTT PUBLISH +// ====================================================== + +String jsonFloat(float value, uint8_t decimals) { + if (isnan(value) || isinf(value)) { + return "null"; + } + + char buffer[24]; + snprintf(buffer, sizeof(buffer), "%.*f", (int)decimals, (double)value); + return String(buffer); +} + +String build_telemetry_payload() { + String payload; + payload.reserve(1100); + + payload += "{"; + + payload += "\"tenant\":\"mesavault_lab\","; + payload += "\"site\":\"bench_aquaponics\","; + payload += "\"product\":\"aquaponics\","; + payload += "\"asset\":\"atlas_kit_01\","; + + payload += "\"ph\":"; + payload += jsonFloat(ph_value, 2); + payload += ","; + + payload += "\"ec_us_cm\":"; + payload += jsonFloat(ec_value, 0); + payload += ","; + + payload += "\"do_mg_l\":"; + payload += jsonFloat(do_value, 2); + payload += ","; + + payload += "\"water_temp_c\":"; + payload += jsonFloat(rtd_value, 2); + payload += ","; + + payload += "\"hum_atlas_pct\":null,"; + payload += "\"co2_ppm\":null,"; + payload += "\"co2_valid\":false,"; + + payload += "\"air_temp_c\":"; + payload += jsonFloat(bme_air_temp, 2); + payload += ","; + + payload += "\"air_humidity_pct\":"; + payload += jsonFloat(bme_air_hum, 1); + payload += ","; + + payload += "\"pressure_hpa\":"; + payload += jsonFloat(bme_pressure_hpa, 1); + payload += ","; + + payload += "\"vpd_kpa\":"; + payload += jsonFloat(bme_vpd_kpa, 2); + payload += ","; + + payload += "\"leaf_temp_c\":"; + payload += jsonFloat(mlx_leaf_temp_c, 2); + payload += ","; + + payload += "\"mlx_ambient_c\":"; + payload += jsonFloat(mlx_ambient_c, 2); + payload += ","; + + payload += "\"leaf_air_delta_c\":"; + payload += jsonFloat(leaf_air_delta_c, 2); + + payload += "}"; + + return payload; +} + +void mqtt_send_if_due() { + if (!send_to_stack) { + return; + } + + unsigned long now = millis(); + + if ((now - last_mqtt_publish_ms) < mqtt_publish_interval_ms) { + return; + } + + last_mqtt_publish_ms = now; + + if (!wifi_isconnected()) { + Serial.println("wifi not connected, mqtt skipped"); + return; + } + + if (!mqtt.connected()) { + reconnect_mqtt(); + } + + if (!mqtt.connected()) { + Serial.println("mqtt not connected, publish skipped"); + return; + } + + String payload = build_telemetry_payload(); + + bool ok = mqtt.publish(mqtt_topic, payload.c_str(), false); + + if (ok) { + Serial.print("sent to MESAVAULT MQTT: "); + Serial.println(payload); + } else { + Serial.println("couldnt send to MESAVAULT MQTT"); + } +} + +// ====================================================== +// SETUP +// ====================================================== + +void setup() { + pinMode(EN_PH, OUTPUT); + pinMode(EN_DO, OUTPUT); + pinMode(EN_RTD, OUTPUT); + pinMode(EN_EC, OUTPUT); + pinMode(EN_HUM, OUTPUT); + pinMode(EN_CO2, OUTPUT); + + digitalWrite(EN_PH, LOW); + digitalWrite(EN_DO, LOW); + digitalWrite(EN_RTD, HIGH); + digitalWrite(EN_EC, LOW); + + // HUM y CO2 deshabilitados + digitalWrite(EN_HUM, LOW); + digitalWrite(EN_CO2, LOW); + + Serial.begin(9600); + delay(500); + + Wire.begin(); + + // Muy conservador para este bus compartido + Wire.setClock(10000); + + // Tiempo para que el hardware quede estable + delay(2000); + + scan_i2c(); + refresh_i2c_state(); + + WiFi.mode(WIFI_STA); + WiFi.setSleep(false); + + mqtt.setServer(mqtt_host, mqtt_port); + mqtt.setBufferSize(1536); + mqtt.setKeepAlive(30); + + reconnect_wifi(); + + Serial.println("Aquaponics Kit hybrid firmware ready (water + BME280 + MLX90614)."); + + if (MQTT_AUTOSTART) { + Serial.println("MQTT telemetry autostart enabled."); + start_datalogging(); + } else { + Serial.println("Use POLL for serial readings."); + Serial.println("Use DATALOG for MQTT telemetry."); + } +} + +// ====================================================== +// LOOP +// ====================================================== + +void loop() { + service_network(); + + String cmd; + + if (receive_command(cmd)) { + if (!process_coms(cmd)) { + polling = false; + send_to_stack = false; + process_command(cmd, device_list, device_list_len, default_board); + } + } + + if (polling) { + unsigned long now = millis(); + + if (first_sensor_cycle || ((now - last_sensor_cycle_ms) >= sensor_cycle_interval_ms)) { + first_sensor_cycle = false; + last_sensor_cycle_ms = now; + run_sensor_cycle(); + } + } + + delay(10); +} + +// ====================================================== +// SENSOR CYCLE +// ====================================================== + +void run_sensor_cycle() { + Serial.println(); + Serial.println("----- hybrid sensor cycle start -----"); + + read_rtd_and_compensate(); + + read_ezo_sensor(PH, ph_ok, ph_value); + read_ezo_sensor(DO, do_ok, do_value); + read_ezo_sensor(EC, ec_ok, ec_value); + + read_bme280(); + wait_with_network(150); + + read_mlx90614(); + wait_with_network(150); + + mqtt_send_if_due(); + + Serial.println(); + Serial.println("----- hybrid sensor cycle end -----"); +} + +bool read_rtd_and_compensate() { + if (!rtd_ok) { + rtd_value = 25.0; + + Serial.print(" RTD:not detected. Using default compensation "); + Serial.print(rtd_value, 2); + Serial.print(" C"); + + PH.send_cmd_with_num("T,", 25.0); + wait_with_network(COMPENSATION_GAP_MS); + + DO.send_cmd_with_num("T,", 20.0); + wait_with_network(COMPENSATION_GAP_MS); + + EC.send_cmd_with_num("T,", 25.0); + wait_with_network(COMPENSATION_GAP_MS); + + Serial.println(); + + return false; + } + + RTD.send_read_cmd(); + wait_with_network(RTD_RESPONSE_DELAY_MS); + + Serial.print(" "); + receive_and_print_reading(RTD); + + if ((RTD.get_error() == Ezo_board::SUCCESS) && (RTD.get_last_received_reading() > -1000.0)) { + rtd_value = RTD.get_last_received_reading(); + } else { + rtd_value = 25.0; + } + + PH.send_cmd_with_num("T,", rtd_value); + wait_with_network(COMPENSATION_GAP_MS); + + DO.send_cmd_with_num("T,", rtd_value); + wait_with_network(COMPENSATION_GAP_MS); + + EC.send_cmd_with_num("T,", rtd_value); + wait_with_network(COMPENSATION_GAP_MS); + + Serial.print(" temp compensation: "); + Serial.print(rtd_value, 2); + Serial.println(" C"); + + wait_with_network(SENSOR_GAP_MS); + + return true; +} + +void read_ezo_sensor(Ezo_board &sensor, bool sensor_ok, float &target_value) { + Serial.print(" "); + + if (!sensor_ok) { + Serial.print(sensor.get_name()); + Serial.print(":not detected"); + target_value = NAN; + wait_with_network(SENSOR_GAP_MS); + return; + } + + sensor.send_read_cmd(); + wait_with_network(EZO_RESPONSE_DELAY_MS); + + receive_and_print_reading(sensor); + + if (sensor.get_error() == Ezo_board::SUCCESS) { + target_value = sensor.get_last_received_reading(); + } else { + target_value = NAN; + } + + wait_with_network(SENSOR_GAP_MS); +} + +// ====================================================== +// BME280 + MLX90614 +// ====================================================== + +float calc_vpd_kpa(float temp_c, float rh_percent) { + if (isnan(temp_c) || isnan(rh_percent)) { + return NAN; + } + + if (rh_percent < 0.0 || rh_percent > 100.0) { + return NAN; + } + + float es = 0.6108 * exp((17.27 * temp_c) / (temp_c + 237.3)); + return es * (1.0 - (rh_percent / 100.0)); +} + +void read_bme280() { + Serial.print(" "); + + if (!bme_ok) { + bme_air_temp = NAN; + bme_air_hum = NAN; + bme_pressure_hpa = NAN; + bme_vpd_kpa = NAN; + + Serial.print("BME280:not detected"); + return; + } + + float temp = bme.readTemperature(); + float hum = bme.readHumidity(); + float pressure = bme.readPressure() / 100.0F; + + if (isnan(temp) || temp < -40.0 || temp > 85.0) { + temp = NAN; + } + + if (isnan(hum) || hum < 0.0 || hum > 100.0) { + hum = NAN; + } + + if (isnan(pressure) || pressure < 300.0 || pressure > 1100.0) { + pressure = NAN; + } + + bme_air_temp = temp; + bme_air_hum = hum; + bme_pressure_hpa = pressure; + + if (!isnan(bme_air_temp) && !isnan(bme_air_hum)) { + bme_vpd_kpa = calc_vpd_kpa(bme_air_temp, bme_air_hum); + } else { + bme_vpd_kpa = NAN; + } + + Serial.print("BME "); + Serial.print(BME_ADDR, HEX); + Serial.print(": "); + Serial.print(bme_air_temp, 2); + Serial.print(" C, "); + Serial.print(bme_air_hum, 1); + Serial.print(" %, "); + Serial.print(bme_pressure_hpa, 1); + Serial.print(" hPa, VPD "); + Serial.print(bme_vpd_kpa, 2); + Serial.print(" kPa"); +} + +void read_mlx90614() { + Serial.print(" "); + + if (!mlx_ok) { + mlx_leaf_temp_c = NAN; + mlx_ambient_c = NAN; + leaf_air_delta_c = NAN; + + Serial.print("MLX90614:not detected"); + return; + } + + float object_temp = mlx.readObjectTempC(); + wait_with_network(100); + float ambient_temp = mlx.readAmbientTempC(); + + if (isnan(object_temp) || object_temp < -20.0 || object_temp > 100.0) { + object_temp = NAN; + } + + if (isnan(ambient_temp) || ambient_temp < -20.0 || ambient_temp > 100.0) { + ambient_temp = NAN; + } + + mlx_leaf_temp_c = object_temp; + mlx_ambient_c = ambient_temp; + + if (!isnan(mlx_leaf_temp_c) && !isnan(bme_air_temp)) { + leaf_air_delta_c = mlx_leaf_temp_c - bme_air_temp; + } else { + leaf_air_delta_c = NAN; + } + + Serial.print("MLX "); + Serial.print(MLX_ADDR, HEX); + Serial.print(": leaf "); + Serial.print(mlx_leaf_temp_c, 2); + Serial.print(" C, ambient "); + Serial.print(mlx_ambient_c, 2); + Serial.print(" C, leaf-air "); + Serial.print(leaf_air_delta_c, 2); + Serial.print(" C"); +} + +// ====================================================== +// COMMANDS +// ====================================================== + +void start_datalogging() { + polling = true; + send_to_stack = true; + first_sensor_cycle = true; + last_mqtt_publish_ms = 0; + + reconnect_wifi(); + reconnect_mqtt(); + + Serial.println("MESAVAULT MQTT datalogging enabled"); +} + +bool process_coms(const String &string_buffer) { + String cmd = string_buffer; + cmd.trim(); + cmd.toUpperCase(); + + if (cmd == "HELP") { + print_help(); + return true; + } + + else if (cmd.startsWith("DATALOG")) { + start_datalogging(); + return true; + } + + else if (cmd == "MQTT:ON") { + send_to_stack = true; + reconnect_mqtt(); + Serial.println("MQTT sending enabled"); + return true; + } + + else if (cmd == "MQTT:OFF") { + send_to_stack = false; + Serial.println("MQTT sending disabled"); + return true; + } + + else if (cmd == "MQTT:STATUS") { + Serial.print("WiFi: "); + Serial.println(wifi_isconnected() ? "connected" : "disconnected"); + + if (wifi_isconnected()) { + Serial.print("IP: "); + Serial.println(WiFi.localIP()); + } + + Serial.print("MQTT host: "); + Serial.println(mqtt_host); + + Serial.print("MQTT port: "); + Serial.println(mqtt_port); + + Serial.print("MQTT connected: "); + Serial.println(mqtt.connected() ? "yes" : "no"); + + Serial.print("send_to_stack: "); + Serial.println(send_to_stack ? "true" : "false"); + + Serial.print("cycle interval ms: "); + Serial.println(sensor_cycle_interval_ms); + + return true; + } + + else if (cmd == "SCAN") { + scan_i2c(); + refresh_i2c_state(); + first_sensor_cycle = true; + return true; + } + + else if (cmd == "BME:R") { + read_bme280(); + Serial.println(); + return true; + } + + else if (cmd == "MLX:R") { + read_bme280(); + wait_with_network(150); + read_mlx90614(); + Serial.println(); + return true; + } + + else if (cmd.startsWith("POLL")) { + polling = true; + send_to_stack = MQTT_AUTOSTART; + + int16_t index = cmd.indexOf(','); + + if (index != -1) { + float new_delay_s = cmd.substring(index + 1).toFloat(); + + if (new_delay_s >= 8.0) { + sensor_cycle_interval_ms = (unsigned long)(new_delay_s * 1000.0); + Serial.print("new sensor cycle interval: "); + Serial.print(new_delay_s, 1); + Serial.println(" s"); + } else { + Serial.println("delay too short; minimum recommended is 8 seconds"); + } + } + + first_sensor_cycle = true; + + return true; + } + + return false; +} + +// ====================================================== +// HELP +// ====================================================== + +void get_ec_k_value() { + if (!ec_ok) { + k_val = 0; + return; + } + + char rx_buf[10]; + + EC.send_cmd("k,?"); + wait_with_network(400); + + if (EC.receive_cmd(rx_buf, 10) == Ezo_board::SUCCESS) { + k_val = String(rx_buf).substring(3).toFloat(); + } +} + +void print_help() { + get_ec_k_value(); + + Serial.println(F("Atlas Scientific I2C Aquaponics kit - water + BME280 + MLX90614")); + Serial.println(F("Commands:")); + Serial.println(F("datalog Start readings + MQTT telemetry")); + Serial.println(F("poll Start readings. With MQTT_AUTOSTART=true, MQTT stays enabled.")); + Serial.println(F("poll,n Poll every n seconds. Minimum recommended: 8 s")); + Serial.println(F("mqtt:on Enable MQTT publishing")); + Serial.println(F("mqtt:off Disable MQTT publishing")); + Serial.println(F("mqtt:status Show WiFi/MQTT status")); + Serial.println(F("scan I2C scan + refresh device state")); + Serial.println(F("bme:r Read BME280")); + Serial.println(F("mlx:r Read MLX90614")); + Serial.println(F("")); + Serial.println(F("Direct EZO commands stop polling/MQTT until DATALOG is sent again.")); + Serial.println(F("")); + Serial.println(F("ph:cal,mid,7 calibrate to pH 7")); + Serial.println(F("ph:cal,low,4 calibrate to pH 4")); + Serial.println(F("ph:cal,high,10 calibrate to pH 10")); + Serial.println(F("ph:cal,clear clear calibration")); + Serial.println(F("")); + Serial.println(F("ec:cal,dry calibrate a dry EC probe")); + Serial.println(F("ec:k,[n] switch K values: 0.1, 1, 10")); + Serial.println(F("ec:cal,clear clear calibration")); + + if (k_val > 9) { + Serial.println(F("For K10 probes:")); + Serial.println(F(" ec:cal,low,12880")); + Serial.println(F(" ec:cal,high,150000")); + } + else if (k_val > .9) { + Serial.println(F("For K1 probes:")); + Serial.println(F(" ec:cal,low,12880")); + Serial.println(F(" ec:cal,high,80000")); + } + else if (k_val > .09) { + Serial.println(F("For K0.1 probes:")); + Serial.println(F(" ec:cal,low,84")); + Serial.println(F(" ec:cal,high,1413")); + } + + Serial.println(F("")); + Serial.println(F("rtd:cal,t calibrate temp probe to chosen value")); + Serial.println(F("rtd:cal,clear clear calibration")); + Serial.println(F("")); + Serial.println(F("do:cal calibrate DO probe to air")); + Serial.println(F("do:cal,0 calibrate DO probe to 0 dissolved oxygen")); + Serial.println(F("do:cal,clear clear calibration")); +} + +// ====================================================== +// I2C +// ====================================================== + +bool i2c_device_present(byte address) { + Wire.beginTransmission(address); + byte error = Wire.endTransmission(); + return (error == 0); +} + +uint8_t read_i2c_reg8(byte address, byte reg) { + Wire.beginTransmission(address); + Wire.write(reg); + + if (Wire.endTransmission(false) != 0) { + return 0xFF; + } + + Wire.requestFrom(address, (uint8_t)1); + + if (Wire.available()) { + return Wire.read(); + } + + return 0xFF; +} + +void detect_i2c_devices() { + ph_ok = i2c_device_present(99); + do_ok = i2c_device_present(97); + rtd_ok = i2c_device_present(102); + ec_ok = i2c_device_present(100); + + Serial.println("I2C device status:"); + Serial.print(" PH 0x63: "); Serial.println(ph_ok ? "OK" : "missing"); + Serial.print(" DO 0x61: "); Serial.println(do_ok ? "OK" : "missing"); + Serial.print(" RTD 0x66: "); Serial.println(rtd_ok ? "OK" : "missing"); + Serial.print(" EC 0x64: "); Serial.println(ec_ok ? "OK" : "missing"); +} + +void refresh_i2c_state() { + detect_i2c_devices(); + + bme_ok = bme.begin(BME_ADDR, &Wire); + + if (bme_ok) { + Serial.println("BME280 ready at 0x76"); + } else { + Serial.println("BME280 not ready at 0x76"); + } + + mlx_ok = mlx.begin(MLX_ADDR, &Wire); + + if (mlx_ok) { + Serial.println("MLX90614 ready at 0x5A"); + } else { + Serial.println("MLX90614 not ready at 0x5A"); + } +} + +void scan_i2c() { + Serial.println("I2C scan:"); + + for (byte address = 1; address < 127; address++) { + Wire.beginTransmission(address); + byte error = Wire.endTransmission(); + + if (error == 0) { + Serial.print(" - found 0x"); + + if (address < 16) { + Serial.print("0"); + } + + Serial.print(address, HEX); + Serial.print(" / "); + Serial.print(address); + Serial.print(" decimal"); + + if (address == 0x5A) Serial.print(" MLX90614"); + if (address == 0x61) Serial.print(" DO"); + if (address == 0x63) Serial.print(" PH"); + if (address == 0x64) Serial.print(" EC"); + if (address == 0x66) Serial.print(" RTD"); + + if (address == 0x76 || address == 0x77) { + uint8_t chip_id = read_i2c_reg8(address, 0xD0); + + Serial.print(" | chip_id=0x"); + Serial.print(chip_id, HEX); + + if (chip_id == 0x60) { + Serial.print(" BME280"); + } else if (chip_id == 0x58) { + Serial.print(" BMP280"); + } + } + + Serial.println(); + } + } + + Serial.println("I2C scan done."); +} + +// ====================================================== +// PUMP FUNCTION +// ====================================================== + +void pump_function(Ezo_board &pump, Ezo_board &sensor, float value, float dose, bool greater_than) { + if (sensor.get_error() == Ezo_board::SUCCESS) { + bool comparison = false; + + if (greater_than) { + comparison = (sensor.get_last_received_reading() >= value); + } else { + comparison = (sensor.get_last_received_reading() <= value); + } + + if (comparison) { + pump.send_cmd_with_num("d,", dose); + wait_with_network(100); + + Serial.print(pump.get_name()); + Serial.print(" "); + + char response[20]; + + if (pump.receive_cmd(response, 20) == Ezo_board::SUCCESS) { + Serial.print("pump dispensed "); + } else { + Serial.print("pump error "); + } + + Serial.println(response); + } else { + pump.send_cmd("x"); + } + } +} +``` + + + +## Mapa I2C actual + +Con el kit en configuración estable, el `SCAN` esperado es: + +```text +I2C scan: + - found 0x5A / 90 decimal MLX90614 + - found 0x61 / 97 decimal DO + - found 0x63 / 99 decimal PH + - found 0x64 / 100 decimal EC + - found 0x66 / 102 decimal RTD + - found 0x76 / 118 decimal | chip_id=0x60 BME280 +I2C scan done. +``` + +| Dispositivo | Dirección decimal | Dirección hex | +| ----------- | ----------------: | ------------: | +| MLX90614 | 90 | 0x5A | +| DO EZO | 97 | 0x61 | +| pH EZO | 99 | 0x63 | +| EC EZO | 100 | 0x64 | +| RTD EZO | 102 | 0x66 | +| BME280 | 118 | 0x76 | + +Direcciones retiradas o no usadas ahora: + +| Dispositivo | Dirección decimal | Dirección hex | Estado | +| ----------- | ----------------: | ------------: | -------- | +| CO2 EZO | 105 | 0x69 | Retirado | +| HUM EZO | 111 | 0x6F | Retirado | +| PMP EZO | 103 | 0x67 | No usado | + +## Sensores conectados + +### pH EZO + +Función: + +```text +Medir pH de la solución. +``` + +Uso en el sistema: + +```text +control de disponibilidad de nutrientes +detección de desviaciones de pH +diagnóstico de solución nutritiva +``` + +Rango objetivo inicial para tomate: + +```text +OK: 6.0–6.5 +Warning: 5.5–6.8 +Peligro: fuera de 5.5–6.8 +``` + +Comandos útiles: + +```text +PH:R +PH:CAL,MID,7 +PH:CAL,LOW,4 +PH:CAL,HIGH,10 +PH:CAL,CLEAR +PH:CAL,? +``` + +### EC EZO + +Función: + +```text +Medir conductividad eléctrica de la solución. +``` + +Uso en el sistema: + +```text +estimar concentración de sales/nutrientes +detectar solución demasiado diluida +detectar acumulación de sales +``` + +Rango inicial usado en dashboard: + +```text +OK: 1500–3500 µS/cm +Warning: 800–5000 µS/cm +Peligro: <800 o >5000 µS/cm +``` + +Comandos útiles: + +```text +EC:R +EC:K,? +EC:K,1 +EC:CAL,DRY +EC:CAL,LOW,12880 +EC:CAL,HIGH,80000 +EC:CAL,CLEAR +EC:CAL,? +``` + +Notas: + +```text +Para sonda K1 se ha usado calibración low con 12880 µS/cm. +Después de EC:CAL,DRY conviene esperar a que la lectura se estabilice. +No calibrar si la lectura no está estable. +``` + +### DO EZO + +Función: + +```text +Medir oxígeno disuelto. +``` + +Uso en el sistema: + +```text +salud de raíz +aireación +biofiltro +riesgo para peces si se usa como acuaponía real +``` + +Rangos iniciales: + +```text +OK: >= 6 mg/L +Warning: 5–6 mg/L +Peligro: <5 mg/L +``` + +Comandos útiles: + +```text +DO:R +DO:CAL +DO:CAL,0 +DO:CAL,CLEAR +DO:CAL,? +``` + +### RTD EZO + +Función: + +```text +Medir temperatura del agua. +``` + +Uso en el sistema: + +```text +temperatura real de solución +compensación térmica de pH, EC y DO +diagnóstico de raíz +``` + +Comandos útiles: + +```text +RTD:R +RTD:CAL, +RTD:CAL,CLEAR +RTD:CAL,? +``` + +Uso en firmware: + +```text +El RTD se lee primero. +Después se envía compensación de temperatura a PH, DO y EC. +``` + +Ejemplo: + +```text +RTD: 20.19 +temp compensation: 20.19 C +``` + +Si falla RTD: + +```text +se usa 25.0 ºC como compensación por defecto +``` + +### BME280 + +Función: + +```text +Medir temperatura de aire, humedad relativa y presión. +``` + +Dirección: + +```text +0x76 +``` + +Variables enviadas: + +```text +air_temp_c +air_humidity_pct +pressure_hpa +vpd_kpa +``` + +Notas: + +```text +El BME280 no se usa para compensar pH/EC/DO. +La compensación de agua debe venir del RTD. +``` + +### MLX90614 + +Función: + +```text +Medir temperatura superficial sin contacto. +``` + +Dirección: + +```text +0x5A +``` + +Variables enviadas: + +```text +leaf_temp_c +mlx_ambient_c +leaf_air_delta_c +``` + +Uso en la vertical: + +```text +temperatura de hoja +riesgo preliminar de estrés hídrico +diferencial hoja-aire +condensación si se cruza con punto de rocío +``` + +Notas importantes: + +```text +El MLX90614 funciona correctamente con cable corto. +El problema no era el sensor ni el soldador. +El fallo aparecía al compartir bus con demasiados sensores o con cableado largo. +``` + +Prueba aislada válida: + +```text +I2C scan: + - found 0x5A / 90 decimal MLX90614 expected address + +MLX90614 initialized OK at 0x5A +Ambient sensor: 19.01 C | Object / leaf: 18.91 C +``` + +## Sensores descartados en esta fase + +### CO2 EZO + +Problema observado: + +```text +Lecturas negativas alrededor de -3100 ppm. +``` + +Diagnóstico: + +```text +Lectura físicamente imposible. +El circuito respondía por I2C, pero entregaba valor inválido. +``` + +Decisión actual: + +```text +CO2 fuera del kit. +No se lee. +No se usa en dashboard. +JSON envía co2_ppm=null y co2_valid=false. +``` + +Posible recuperación futura: + +```text +montarlo en otro microcontrolador +probar sensor nuevo +separarlo del bus I2C principal +``` + +### HUM EZO + +Problema: + +```text +Añade carga al bus I2C. +No es necesario porque el BME280 ya mide humedad del aire. +``` + +Decisión actual: + +```text +HUM EZO fuera del kit. +``` + +El payload mantiene: + +```json +"hum_atlas_pct": null +``` + +## Pines enable del Aquaponics Kit + +Para board version `1.7`: + +```cpp +const int EN_PH = 13; +const int EN_DO = 12; +const int EN_RTD = 33; +const int EN_EC = 27; +const int EN_HUM = 32; +const int EN_CO2 = 15; +``` + +Estados usados en la configuración estable: + +```cpp +digitalWrite(EN_PH, LOW); +digitalWrite(EN_DO, LOW); +digitalWrite(EN_RTD, HIGH); +digitalWrite(EN_EC, LOW); + +digitalWrite(EN_HUM, LOW); // deshabilitado +digitalWrite(EN_CO2, LOW); // deshabilitado +``` + +Para board version `1.8`, los pines cambian: + +```cpp +// const int EN_PH = 12; +// const int EN_DO = 27; +// const int EN_RTD = 15; +// const int EN_EC = 33; +// const int EN_HUM = 14; +// const int EN_CO2 = 32; +``` + +Antes de reutilizar el firmware en otra placa, confirmar versión física del kit. + +## Estrategia de lectura del firmware + +El firmware actual no lee todos los sensores de golpe. Lee de forma secuencial para no forzar el bus. + +Secuencia: + +```text +1. Leer RTD +2. Enviar compensación de temperatura a PH, DO y EC +3. Leer PH +4. Leer DO +5. Leer EC +6. Leer BME280 +7. Leer MLX90614 +8. Construir JSON +9. Publicar MQTT +``` + +Tiempos usados: + +```cpp +const unsigned long EZO_RESPONSE_DELAY_MS = 1800; +const unsigned long RTD_RESPONSE_DELAY_MS = 2000; +const unsigned long SENSOR_GAP_MS = 300; +const unsigned long COMPENSATION_GAP_MS = 250; +unsigned long sensor_cycle_interval_ms = 12000; +const unsigned long mqtt_publish_interval_ms = 5000; +``` + +I2C: + +```cpp +Wire.begin(); +Wire.setClock(10000); +``` + +Motivo: + +```text +El bus I2C compartido con varios EZO y sensores externos es sensible. +Se prioriza estabilidad por encima de velocidad. +``` + +## Payload MQTT + +Topic: + +```text +vertical/mesavault_lab/bench_aquaponics/aquaponics/atlas_kit_01/telemetry +``` + +Ejemplo real: + +```json +{ + "tenant": "mesavault_lab", + "site": "bench_aquaponics", + "product": "aquaponics", + "asset": "atlas_kit_01", + "ph": 8.54, + "ec_us_cm": 327, + "do_mg_l": 3.83, + "water_temp_c": 20.19, + "hum_atlas_pct": null, + "co2_ppm": null, + "co2_valid": false, + "air_temp_c": 18.09, + "air_humidity_pct": 77.9, + "pressure_hpa": 1007.6, + "vpd_kpa": 0.46, + "leaf_temp_c": 19.27, + "mlx_ambient_c": 18.81, + "leaf_air_delta_c": 1.18 +} +``` + +| Campo | Significado | +| ------------------ | ------------------------------------ | +| `ph` | pH de solución | +| `ec_us_cm` | EC en µS/cm | +| `do_mg_l` | oxígeno disuelto | +| `water_temp_c` | temperatura de agua | +| `hum_atlas_pct` | humedad Atlas, actualmente null | +| `co2_ppm` | CO2, actualmente null | +| `co2_valid` | false mientras CO2 esté retirado | +| `air_temp_c` | temperatura aire BME280 | +| `air_humidity_pct` | humedad aire BME280 | +| `pressure_hpa` | presión BME280 | +| `vpd_kpa` | VPD calculado | +| `leaf_temp_c` | temperatura superficial hoja | +| `mlx_ambient_c` | temperatura ambiente interna del MLX | +| `leaf_air_delta_c` | hoja - aire | + +## MQTT + +### Parámetros del firmware + +```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 = "PASSWORD_EN_VAULTWARDEN"; + +const char* mqtt_client_id = "atlas_kit_01"; + +const char* mqtt_topic = + "vertical/mesavault_lab/bench_aquaponics/aquaponics/atlas_kit_01/telemetry"; +``` + +### Autostart + +El firmware debe tener: + +```cpp +const bool MQTT_AUTOSTART = true; +``` + +Esto evita tener que escribir: + +```text +DATALOG +``` + +Problema resuelto: + +```text +Antes el kit conectaba al broker, pero solo enviaba PINGREQ. +No publicaba payload hasta escribir DATALOG por serie. +``` + +Señal mala en OVH: + +```text +Received PINGREQ from atlas_kit_01 +``` + +Señal buena en OVH: + +```text +Received PUBLISH from atlas_kit_01 +Sending PUBLISH to local...to-platform40 +``` + +## Comandos serie disponibles + +Baudios: + +```text +9600 +``` + +Comandos principales: + +```text +HELP +SCAN +DATALOG +POLL +POLL,12 +MQTT:STATUS +MQTT:ON +MQTT:OFF +BME:R +MLX:R +``` + +### SCAN + +Uso: + +```text +SCAN +``` + +Función: + +```text +escanea bus I2C +actualiza estado interno de dispositivos +reinicializa BME280 y MLX90614 +fuerza nuevo ciclo +``` + +Salida esperada: + +```text +I2C scan: + - found 0x5A / 90 decimal MLX90614 + - found 0x61 / 97 decimal DO + - found 0x63 / 99 decimal PH + - found 0x64 / 100 decimal EC + - found 0x66 / 102 decimal RTD + - found 0x76 / 118 decimal | chip_id=0x60 BME280 +I2C scan done. +``` + +### DATALOG + +Uso: + +```text +DATALOG +``` + +Función: + +```text +activa polling +activa publicación MQTT +fuerza ciclo nuevo +``` + +En firmware actual debería arrancar solo, pero este comando sirve para reactivar si se ha parado. + +### POLL + +Uso: + +```text +POLL +POLL,15 +``` + +Función: + +```text +activa lectura de sensores +permite cambiar intervalo de ciclo +``` + +En este firmware, si `MQTT_AUTOSTART=true`, `POLL` mantiene MQTT activo. + +### MQTT:STATUS + +Uso: + +```text +MQTT:STATUS +``` + +Salida esperada: + +```text +WiFi: connected +IP: ... +MQTT host: mqtt.mesavault.es +MQTT port: 1883 +MQTT connected: yes +send_to_stack: true +cycle interval ms: 12000 +``` + +### BME:R + +Uso: + +```text +BME:R +``` + +Lee solo `BME280`. + +### MLX:R + +Uso: + +```text +MLX:R +``` + +Lee `BME280` y `MLX90614` para calcular `leaf_air_delta_c`. + +## Calibración + +### pH + +Secuencia típica: + +```text +PH:CAL,CLEAR +PH:CAL,MID,7 +PH:CAL,LOW,4 +PH:CAL,HIGH,10 +PH:CAL,? +``` + +Notas: + +```text +Calibrar primero punto medio pH 7. +No calibrar si la lectura no está estable. +Aclarar sonda entre soluciones. +``` + +### EC + +Consultar K: + +```text +EC:K,? +``` + +Para K1: + +```text +EC:CAL,CLEAR +EC:CAL,DRY +EC:CAL,LOW,12880 +EC:CAL,HIGH,80000 +EC:CAL,? +``` + +Notas: + +```text +EC:CAL,DRY debe hacerse con sonda seca. +LOW con solución 12880 µS/cm. +Esperar estabilización antes de enviar CAL. +No corregir una lectura inestable con calibración. +``` + +### DO + +Calibración en aire: + +```text +DO:CAL +``` + +Calibración a cero: + +```text +DO:CAL,0 +``` + +Borrar calibración: + +```text +DO:CAL,CLEAR +``` + +Consultar: + +```text +DO:CAL,? +``` + +Notas: + +```text +La sonda DO necesita tiempo de estabilización. +Revisar membrana, burbujas y solución interna si la lectura es incoherente. +``` + +### RTD + +Calibración: + +```text +RTD:CAL, +``` + +Ejemplo: + +```text +RTD:CAL,20.0 +``` + +Borrar: + +```text +RTD:CAL,CLEAR +``` + +Notas: + +```text +El RTD condiciona la compensación de pH, EC y DO. +Si falla, el firmware usa 25 ºC por defecto. +``` + +## Validación local del kit + +### Validación por monitor serie + +Al arrancar debe aparecer algo parecido a: + +```text +Aquaponics Kit hybrid firmware ready (water + BME280 + MLX90614). +MQTT telemetry autostart enabled. +MESAVAULT MQTT datalogging enabled +``` + +Ciclo correcto: + +```text +----- hybrid sensor cycle start ----- + RTD: 20.19 temp compensation: 20.19 C + PH: 8.54 DO: 3.83 EC: 327.20 + BME 76: 18.09 C, 77.9 %, 1007.6 hPa, VPD 0.46 kPa + MLX 5A: leaf 19.27 C, ambient 18.81 C, leaf-air 1.18 C +sent to MESAVAULT MQTT: {...} +----- hybrid sensor cycle end ----- +``` + +### Validación en OVH + +```bash +docker logs -f mv_mqtt_public 2>&1 | grep --line-buffered -E 'atlas_kit_01|Received PUBLISH|PINGREQ|not authorised|Bad username' +``` + +Bueno: + +```text +Received PUBLISH from atlas_kit_01 +``` + +Insuficiente: + +```text +Received PINGREQ from atlas_kit_01 +``` + +### Validación en platform-40 + +```bash +docker exec -it mv_mosquitto \ + mosquitto_sub \ + -h 127.0.0.1 \ + -p 1883 \ + -t 'vertical/mesavault_lab/bench_aquaponics/#' \ + -v +``` + +Debe verse el JSON publicado por el kit. + +### Validación en PostgreSQL + +```sql +SELECT + ts, + EXTRACT(EPOCH FROM (now() - ts))::int AS age_s, + ph, + ec_us_cm, + do_mg_l, + water_temp_c, + air_temp_c, + air_humidity_pct, + pressure_hpa, + vpd_kpa, + leaf_temp_c, + mlx_ambient_c, + leaf_air_delta_c +FROM mv_hot.aquaponics_readings +WHERE asset = 'atlas_kit_01' +ORDER BY ts DESC +LIMIT 10; +``` + +Criterio: + +```text +age_s bajo = está llegando dato actual +``` + +## Problemas conocidos y resolución + +### El kit conecta, pero no publica + +Síntoma en OVH: + +```text +Received PINGREQ from atlas_kit_01 +Sending PINGRESP to atlas_kit_01 +``` + +Causa: + +```text +el firmware está conectado, pero no está publicando payload +``` + +Solución: + +```text +verificar MQTT_AUTOSTART=true +escribir DATALOG +comprobar send_to_stack=true con MQTT:STATUS +``` + +### MLX90614 no aparece + +Si el MLX no aparece en I2C: + +```text +no aparece 0x5A +``` + +Comprobar: + +```text +cable corto +alimentación 3.3 V +SDA/SCL correctos +masa común +bus sin CO2/HUM +``` + +Diagnóstico realizado: + +```text +MLX solo con cable corto funciona. +MLX con bus cargado falla. +``` + +Conclusión: + +```text +no era sensor dañado +era convivencia/carga del bus I2C +``` + +### RTD detectado pero devuelve No Data + +Síntoma: + +```text +RTD: No Data +``` + +Interpretación: + +```text +el circuito está en el bus, pero no devolvió lectura válida en ese ciclo +``` + +Soluciones aplicadas: + +```text +RTD_RESPONSE_DELAY_MS=2000 +bus a 10 kHz +lectura secuencial +pausas entre sensores +``` + +Si persiste: + +```text +revisar conexión RTD +revisar sonda +probar RTD:R manual +probar sin BME/MLX +``` + +### Direcciones raras como 0x01 + +Síntoma: + +```text +I2C scan: + - found 0x01 +``` + +Interpretación: + +```text +bus I2C sucio, inestable o cargado +``` + +Causas posibles: + +```text +pull-ups acumulados +cable largo +sensor bloqueando SDA/SCL +CO2/HUM alterando el bus +ruido eléctrico +``` + +Solución: + +```text +retirar sensores problemáticos +cable corto +bus a 10 kHz +probar incrementalmente +``` + +### Picos falsos en Grafana + +Síntoma: + +```text +temperatura 180 ºC +VPD >100 kPa +pH/EC/DO a cero simultáneamente +``` + +Interpretación: + +```text +muestra corrupta +``` + +Solución aplicada: + +```text +vista mv_hot.aquaponics_readings_clean +vista mv_hot.aquaponics_dashboard +``` + +No borrar datos brutos. Filtrarlos para visualización y diagnóstico. + +### CO2 negativo + +Síntoma: + +```text +CO2:R → -3100 ppm +``` + +Interpretación: + +```text +lectura físicamente imposible +``` + +Decisión: + +```text +CO2 retirado +co2_ppm=null +co2_valid=false +``` + +## Buenas prácticas de hardware + +### Cableado I2C + +Evitar: + +```text +cables largos +más de 1 metro de I2C +muchos módulos con pull-ups en paralelo +pasar cerca de fuentes, relés o potencia +``` + +Recomendado: + +```text +cable corto +GND común +SDA/SCL bien identificados +bus a 10 kHz si está cargado +``` + +### Si se necesitan sensores lejanos + +No usar I2C directo largo. + +Alternativas: + +```text +otro microcontrolador cerca del sensor +RS485 +MQTT por WiFi +LoRa +multiplexor TCA9548A +extensor I2C diferencial +``` + +### Si se quiere recuperar CO2 + +No conectarlo directamente al bus actual. + +Opciones: + +```text +nuevo microcontrolador solo para CO2 +TCA9548A +otro bus I2C en ESP32 si hay pines disponibles +sensor diferente +``` + +## Buenas prácticas de firmware + +### No publicar valores falsos como cero + +Si una lectura falla: + +```text +publicar null +``` + +No publicar: + +```text +0 +``` + +salvo que el valor cero sea físicamente posible y medido con certeza. + +### Publicar después del ciclo completo + +No publicar al inicio del ciclo. + +Orden correcto: + +```text +leer todo +validar básico +calcular derivados +publicar +``` + +### Mantener payload_json + +Aunque se filtren valores en vistas SQL, conservar payload bruto en PostgreSQL. + +Motivo: + +```text +diagnóstico posterior +auditoría +depuración de firmware +comparación con Grafana +``` + +## Relación con Grafana + +El kit no sabe nada de Grafana. + +El flujo es: + +```text +kit → MQTT → sink → PostgreSQL → vistas SQL → Grafana +``` + +Paneles que dependen del kit: + +```text +pH +EC +DO +temperatura agua +temperatura aire +humedad aire +presión +VPD +temperatura hoja +leaf-air +diagnóstico operativo +riesgo condensación +riesgo estrés hídrico +``` + +La vista recomendada para paneles es: + +```text +mv_hot.aquaponics_dashboard +``` + +La vista recomendada para explicación textual es: + +```text +mv_hot.aquaponics_diagnosis_latest +``` + +## Estado actual del kit + +```text +Nombre lógico: atlas_kit_01 +Tenant: mesavault_lab +Site: bench_aquaponics +Product: aquaponics +Topic: vertical/mesavault_lab/bench_aquaponics/aquaponics/atlas_kit_01/telemetry +Estado: funcional con pH + DO + RTD + EC + BME280 + MLX90614 +CO2: retirado +HUM Atlas: retirado +Publicación MQTT: automática +ThingSpeak: eliminado +``` + +## Próximas ampliaciones posibles + +### Nodo climático separado + +Si se decide separar `BME280` y `MLX90614` del kit principal: + +```text +Pycom / ESP32 / microcontrolador dedicado + ↓ +BME280 +MLX90614 + ↓ +MQTT +``` + +Topic sugerido: + +```text +vertical/mesavault_lab/bench_aquaponics/climate/bme_ir_node_01/telemetry +``` + +### Recuperar CO2 + +Topic sugerido si va en nodo aparte: + +```text +vertical/mesavault_lab/bench_aquaponics/climate/co2_node_01/telemetry +``` + +### Añadir más sensores IR + +Por ejemplo: + +```text +varias hojas +varias alturas +varios puntos del invernadero +``` + +No meter todos en el mismo bus del Aquaponics Kit sin multiplexar. + +### Convertirlo en kit comercial + +Para escalar esta vertical, el kit debería quedar definido como: + +```text +Kit Hydro Climate v1 +- Aquaponics Kit con pH/EC/DO/RTD +- nodo climático separado con BME/IR +- MQTT estándar +- dashboard base +- diagnóstico SQL +- alertas Grafana +- runbook de instalación +- checklist de calibración +``` + +## Checklist rápido de puesta en marcha + +1. Encender kit. +2. Abrir monitor serie a `9600`. +3. Confirmar: + +```text +MQTT telemetry autostart enabled +``` + +4. Ejecutar: + +```text +SCAN +``` + +5. Confirmar sensores: + +```text +0x61 DO +0x63 PH +0x64 EC +0x66 RTD +0x76 BME280 +0x5A MLX90614 +``` + +6. Esperar ciclo: + +```text +sent to MESAVAULT MQTT: {...} +``` + +7. En OVH confirmar: + +```text +Received PUBLISH from atlas_kit_01 +``` + +8. En `platform-40` confirmar: + +```bash +docker logs -f aquaponics_atlas01_pg +``` + +9. En PostgreSQL confirmar `age_s` bajo. +10. En Grafana confirmar actualización de paneles. + +## Regla final + +El Aquaponics Kit no debe crecer indefinidamente como placa donde conectar todo. + +Su función estable debe ser: + +```text +agua + lectura básica de clima/planta si el bus lo permite +``` + +Si añadir sensores compromete estabilidad, la decisión correcta es: + +```text +separar sensores en otro nodo +mantener MQTT como integración común +unificar en PostgreSQL/Grafana +``` + +## Huecos y validaciones pendientes + +### Huecos documentales detectados + +* `source_date` no especificado. +* `last_verified` no especificado. +* `owner` no especificado. +* Sensibilidad documental pendiente de confirmar. +* No consta versión exacta del firmware. +* No consta commit, hash o release del firmware. +* No consta versión física confirmada de la placa desplegada. +* No consta ubicación física exacta del kit dentro del lab. +* No consta procedimiento de backup del firmware. +* No consta política de rotación de la contraseña MQTT. + +### Validaciones pendientes + +* Confirmar que el firmware pegado entre `BEGIN_FIRMWARE_ATLAS_KIT_01` y `END_FIRMWARE_ATLAS_KIT_01` no contiene secretos reales. +* Confirmar que `MQTT_AUTOSTART=true`. +* Confirmar que `mqtt_pass` usa placeholder y la credencial real está en Vaultwarden. +* Confirmar que el `SCAN` coincide con la configuración estable. +* Confirmar que `platform-40` recibe el topic `vertical/mesavault_lab/bench_aquaponics/#`. +* Confirmar que PostgreSQL actualiza `mv_hot.aquaponics_readings`. +* Confirmar que Grafana consume `mv_hot.aquaponics_dashboard`. +* Confirmar si este documento debe marcarse como `canonical=true`.