46 KiB
REPOSITORIO
mesavault-knowledge
RUTA_FINAL
runbooks/hydro-aquaponics-climate/atlas-scientific-wifi-aquaponics-kit.md
CONTENIDO
Runbook operativo: Atlas Scientific WiFi Aquaponics Kit
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:
Aquaponics Kit
↓ WiFi
mqtt.mesavault.es:1883
↓
OVH
↓ bridge MQTT por Tailscale
platform-40
↓
PostgreSQL
↓
Grafana
Estado actual
Sensores activos
- pH EZO
- EC EZO
- DO EZO
- RTD EZO
- BME280
- MLX90614 IR
Sensores retirados
- CO2 EZO
- HUM EZO del kit Atlas
Motivo:
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:
pH + DO + RTD + EC + BME280 + MLX90614
Configuración no recomendada:
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.
#include <iot_cmd.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <Ezo_i2c_util.h>
#include <Ezo_i2c.h>
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
#include <Adafruit_MLX90614.h>
#include <math.h>
// ======================================================
// 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:
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:
Medir pH de la solución.
Uso en el sistema:
control de disponibilidad de nutrientes
detección de desviaciones de pH
diagnóstico de solución nutritiva
Rango objetivo inicial para tomate:
OK: 6.0–6.5
Warning: 5.5–6.8
Peligro: fuera de 5.5–6.8
Comandos útiles:
PH:R
PH:CAL,MID,7
PH:CAL,LOW,4
PH:CAL,HIGH,10
PH:CAL,CLEAR
PH:CAL,?
EC EZO
Función:
Medir conductividad eléctrica de la solución.
Uso en el sistema:
estimar concentración de sales/nutrientes
detectar solución demasiado diluida
detectar acumulación de sales
Rango inicial usado en dashboard:
OK: 1500–3500 µS/cm
Warning: 800–5000 µS/cm
Peligro: <800 o >5000 µS/cm
Comandos útiles:
EC:R
EC:K,?
EC:K,1
EC:CAL,DRY
EC:CAL,LOW,12880
EC:CAL,HIGH,80000
EC:CAL,CLEAR
EC:CAL,?
Notas:
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:
Medir oxígeno disuelto.
Uso en el sistema:
salud de raíz
aireación
biofiltro
riesgo para peces si se usa como acuaponía real
Rangos iniciales:
OK: >= 6 mg/L
Warning: 5–6 mg/L
Peligro: <5 mg/L
Comandos útiles:
DO:R
DO:CAL
DO:CAL,0
DO:CAL,CLEAR
DO:CAL,?
RTD EZO
Función:
Medir temperatura del agua.
Uso en el sistema:
temperatura real de solución
compensación térmica de pH, EC y DO
diagnóstico de raíz
Comandos útiles:
RTD:R
RTD:CAL,<temperatura>
RTD:CAL,CLEAR
RTD:CAL,?
Uso en firmware:
El RTD se lee primero.
Después se envía compensación de temperatura a PH, DO y EC.
Ejemplo:
RTD: 20.19
temp compensation: 20.19 C
Si falla RTD:
se usa 25.0 ºC como compensación por defecto
BME280
Función:
Medir temperatura de aire, humedad relativa y presión.
Dirección:
0x76
Variables enviadas:
air_temp_c
air_humidity_pct
pressure_hpa
vpd_kpa
Notas:
El BME280 no se usa para compensar pH/EC/DO.
La compensación de agua debe venir del RTD.
MLX90614
Función:
Medir temperatura superficial sin contacto.
Dirección:
0x5A
Variables enviadas:
leaf_temp_c
mlx_ambient_c
leaf_air_delta_c
Uso en la vertical:
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:
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:
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:
Lecturas negativas alrededor de -3100 ppm.
Diagnóstico:
Lectura físicamente imposible.
El circuito respondía por I2C, pero entregaba valor inválido.
Decisión actual:
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:
montarlo en otro microcontrolador
probar sensor nuevo
separarlo del bus I2C principal
HUM EZO
Problema:
Añade carga al bus I2C.
No es necesario porque el BME280 ya mide humedad del aire.
Decisión actual:
HUM EZO fuera del kit.
El payload mantiene:
"hum_atlas_pct": null
Pines enable del Aquaponics Kit
Para 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;
Estados usados en la configuración estable:
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:
// 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:
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:
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:
Wire.begin();
Wire.setClock(10000);
Motivo:
El bus I2C compartido con varios EZO y sensores externos es sensible.
Se prioriza estabilidad por encima de velocidad.
Payload MQTT
Topic:
vertical/mesavault_lab/bench_aquaponics/aquaponics/atlas_kit_01/telemetry
Ejemplo real:
{
"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
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:
const bool MQTT_AUTOSTART = true;
Esto evita tener que escribir:
DATALOG
Problema resuelto:
Antes el kit conectaba al broker, pero solo enviaba PINGREQ.
No publicaba payload hasta escribir DATALOG por serie.
Señal mala en OVH:
Received PINGREQ from atlas_kit_01
Señal buena en OVH:
Received PUBLISH from atlas_kit_01
Sending PUBLISH to local...to-platform40
Comandos serie disponibles
Baudios:
9600
Comandos principales:
HELP
SCAN
DATALOG
POLL
POLL,12
MQTT:STATUS
MQTT:ON
MQTT:OFF
BME:R
MLX:R
SCAN
Uso:
SCAN
Función:
escanea bus I2C
actualiza estado interno de dispositivos
reinicializa BME280 y MLX90614
fuerza nuevo ciclo
Salida esperada:
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:
DATALOG
Función:
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:
POLL
POLL,15
Función:
activa lectura de sensores
permite cambiar intervalo de ciclo
En este firmware, si MQTT_AUTOSTART=true, POLL mantiene MQTT activo.
MQTT:STATUS
Uso:
MQTT:STATUS
Salida esperada:
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:
BME:R
Lee solo BME280.
MLX:R
Uso:
MLX:R
Lee BME280 y MLX90614 para calcular leaf_air_delta_c.
Calibración
pH
Secuencia típica:
PH:CAL,CLEAR
PH:CAL,MID,7
PH:CAL,LOW,4
PH:CAL,HIGH,10
PH:CAL,?
Notas:
Calibrar primero punto medio pH 7.
No calibrar si la lectura no está estable.
Aclarar sonda entre soluciones.
EC
Consultar K:
EC:K,?
Para K1:
EC:CAL,CLEAR
EC:CAL,DRY
EC:CAL,LOW,12880
EC:CAL,HIGH,80000
EC:CAL,?
Notas:
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:
DO:CAL
Calibración a cero:
DO:CAL,0
Borrar calibración:
DO:CAL,CLEAR
Consultar:
DO:CAL,?
Notas:
La sonda DO necesita tiempo de estabilización.
Revisar membrana, burbujas y solución interna si la lectura es incoherente.
RTD
Calibración:
RTD:CAL,<temperatura_real>
Ejemplo:
RTD:CAL,20.0
Borrar:
RTD:CAL,CLEAR
Notas:
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:
Aquaponics Kit hybrid firmware ready (water + BME280 + MLX90614).
MQTT telemetry autostart enabled.
MESAVAULT MQTT datalogging enabled
Ciclo correcto:
----- 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
docker logs -f mv_mqtt_public 2>&1 | grep --line-buffered -E 'atlas_kit_01|Received PUBLISH|PINGREQ|not authorised|Bad username'
Bueno:
Received PUBLISH from atlas_kit_01
Insuficiente:
Received PINGREQ from atlas_kit_01
Validación en platform-40
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
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:
age_s bajo = está llegando dato actual
Problemas conocidos y resolución
El kit conecta, pero no publica
Síntoma en OVH:
Received PINGREQ from atlas_kit_01
Sending PINGRESP to atlas_kit_01
Causa:
el firmware está conectado, pero no está publicando payload
Solución:
verificar MQTT_AUTOSTART=true
escribir DATALOG
comprobar send_to_stack=true con MQTT:STATUS
MLX90614 no aparece
Si el MLX no aparece en I2C:
no aparece 0x5A
Comprobar:
cable corto
alimentación 3.3 V
SDA/SCL correctos
masa común
bus sin CO2/HUM
Diagnóstico realizado:
MLX solo con cable corto funciona.
MLX con bus cargado falla.
Conclusión:
no era sensor dañado
era convivencia/carga del bus I2C
RTD detectado pero devuelve No Data
Síntoma:
RTD: No Data
Interpretación:
el circuito está en el bus, pero no devolvió lectura válida en ese ciclo
Soluciones aplicadas:
RTD_RESPONSE_DELAY_MS=2000
bus a 10 kHz
lectura secuencial
pausas entre sensores
Si persiste:
revisar conexión RTD
revisar sonda
probar RTD:R manual
probar sin BME/MLX
Direcciones raras como 0x01
Síntoma:
I2C scan:
- found 0x01
Interpretación:
bus I2C sucio, inestable o cargado
Causas posibles:
pull-ups acumulados
cable largo
sensor bloqueando SDA/SCL
CO2/HUM alterando el bus
ruido eléctrico
Solución:
retirar sensores problemáticos
cable corto
bus a 10 kHz
probar incrementalmente
Picos falsos en Grafana
Síntoma:
temperatura 180 ºC
VPD >100 kPa
pH/EC/DO a cero simultáneamente
Interpretación:
muestra corrupta
Solución aplicada:
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:
CO2:R → -3100 ppm
Interpretación:
lectura físicamente imposible
Decisión:
CO2 retirado
co2_ppm=null
co2_valid=false
Buenas prácticas de hardware
Cableado I2C
Evitar:
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:
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:
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:
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:
publicar null
No publicar:
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:
leer todo
validar básico
calcular derivados
publicar
Mantener payload_json
Aunque se filtren valores en vistas SQL, conservar payload bruto en PostgreSQL.
Motivo:
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:
kit → MQTT → sink → PostgreSQL → vistas SQL → Grafana
Paneles que dependen del kit:
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:
mv_hot.aquaponics_dashboard
La vista recomendada para explicación textual es:
mv_hot.aquaponics_diagnosis_latest
Estado actual del kit
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:
Pycom / ESP32 / microcontrolador dedicado
↓
BME280
MLX90614
↓
MQTT
Topic sugerido:
vertical/mesavault_lab/bench_aquaponics/climate/bme_ir_node_01/telemetry
Recuperar CO2
Topic sugerido si va en nodo aparte:
vertical/mesavault_lab/bench_aquaponics/climate/co2_node_01/telemetry
Añadir más sensores IR
Por ejemplo:
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:
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
- Encender kit.
- Abrir monitor serie a
9600. - Confirmar:
MQTT telemetry autostart enabled
- Ejecutar:
SCAN
- Confirmar sensores:
0x61 DO
0x63 PH
0x64 EC
0x66 RTD
0x76 BME280
0x5A MLX90614
- Esperar ciclo:
sent to MESAVAULT MQTT: {...}
- En OVH confirmar:
Received PUBLISH from atlas_kit_01
- En
platform-40confirmar:
docker logs -f aquaponics_atlas01_pg
- En PostgreSQL confirmar
age_sbajo. - 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:
agua + lectura básica de clima/planta si el bus lo permite
Si añadir sensores compromete estabilidad, la decisión correcta es:
separar sensores en otro nodo
mantener MQTT como integración común
unificar en PostgreSQL/Grafana
Huecos y validaciones pendientes
Huecos documentales detectados
source_dateno especificado.last_verifiedno especificado.ownerno 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_01yEND_FIRMWARE_ATLAS_KIT_01no contiene secretos reales. - Confirmar que
MQTT_AUTOSTART=true. - Confirmar que
mqtt_passusa placeholder y la credencial real está en Vaultwarden. - Confirmar que el
SCANcoincide con la configuración estable. - Confirmar que
platform-40recibe el topicvertical/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.