25-kits/atlas-scientific-wifi-aquaponics-kit.md

2427 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.
<!-- BEGIN_FIRMWARE_ATLAS_KIT_01 -->
```cpp
#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");
}
}
}
```
<!-- END_FIRMWARE_ATLAS_KIT_01 -->
## 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.06.5
Warning: 5.56.8
Peligro: fuera de 5.56.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: 15003500 µS/cm
Warning: 8005000 µ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: 56 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,<temperatura>
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,<temperatura_real>
```
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`.