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`.