Contents β 27 sections
Project Overview
Keeps fish and plants alive together by holding dissolved oxygen, pH and temperature in the narrow band where both the fish and the bacteria that feed the plants can thrive.
Aquaponics is a three-way partnership that only works if the water stays in balance. Fish produce ammonia-rich waste; a colony of nitrifying bacteria converts that ammonia first to nitrite and then to nitrate; the plants take up the nitrate as fertiliser and, in doing so, clean the water that returns to the fish. Every member of this loop is fragile in a different way, and they disagree about the ideal conditions: fish and bacteria want a near-neutral pH and plenty of dissolved oxygen, plants would often prefer more acidity, and the bacteria are slow to recover if you ever let them die. This controller's job is to hold the compromise β the band where all three survive β and to intervene fast when the most dangerous variable, dissolved oxygen, starts to fall.
Of everything the controller watches, dissolved oxygen is the one that kills in minutes rather than days. Fish suffocate, and the aerobic bacteria that run the nitrogen cycle stop working, if oxygen crashes β which it does fastest on warm nights when warm water holds less oxygen and respiration is high, exactly when no one is watching. So the controller treats aeration as a life-support system: it runs aerators to keep DO above a hard floor, and it is built so that the safe failure mode is "aerator on". pH is managed more gently, by slow dosing toward a target, because a sudden pH swing is itself a shock to fish and bacteria. Water temperature is monitored because it sets how much oxygen the water can hold and how fast everything metabolises, and because most systems can heat but not easily cool.
Underlying all of it is respect for the nitrogen cycle. Ammonia and nitrite are acutely toxic to fish; nitrate is not. A healthy system converts the first two to the third quickly, but that conversion depends on the bacteria, which depend on oxygen, pH and temperature staying in range β so the water-chemistry sensors are really watching the health of the invisible bacterial workforce as much as the water itself. The controller logs trends, alarms on any excursion toward danger, and is deliberately conservative: when in doubt, aerate, dose slowly, and call for a human. In a living system you cannot reboot, that conservatism is the whole point.
What this project does
- Monitors dissolved oxygen, pH, water temperature and EC/TDS continuously
- Runs aerators to hold dissolved oxygen above a hard safety floor
- Doses slowly to hold pH near the fish/bacteria/plant compromise target
- Watches for conditions that stress the nitrogen-cycle bacteria
- Fails safe β loss of control defaults to aeration on
- Logs water-chemistry trends and alarms on any drift toward danger
- Reports to a dashboard and escalates critical (DO) alarms immediately
Real-World Applications
| Setting | How it is used |
|---|---|
| Backyard / educational aquaponics | A hobby or school system growing fish and vegetables together, where automated life-support prevents the classic overnight DO crash that wipes out the fish. |
| Commercial aquaponic farms | Larger recirculating systems where continuous DO/pH control and logging protect both crops and a valuable fish stock and provide records for buyers. |
| Recirculating aquaculture (RAS) | Fish-only intensive systems where dissolved-oxygen management is the single most critical control loop. |
| Research and demonstration systems | Controlled water-chemistry logging for studying nitrogen-cycle dynamics and crop/fish performance. |
Deployment contexts where a build of this kind earns its keep.
Features & Capabilities
- DO as life-support: aeration held above a hard floor, fail-safe on
- Gentle proportional pH dosing that avoids shocking the system
- Temperature-aware oxygen expectations (warm water holds less Oβ)
- EC/TDS trend as a proxy for nutrient load and water changes
- Nitrogen-cycle-aware alarms, not just single-variable thresholds
- Conservative, human-in-the-loop control for a system you cannot reboot
- Immediate escalation for dissolved-oxygen emergencies
Difficulty, Time & Required Skills
| Attribute | Value |
|---|---|
| Difficulty level | Advanced |
| Estimated completion time | 14β20 hours |
| Indicative build cost | βΉ5,500 β βΉ7,500 |
| Primary discipline | Agriculture |
| Reference platform | ESP32 DevKit V1 (ESP-WROOM-32) |
Skills you should have (or will pick up)
- Calibrating and reading pH, dissolved-oxygen and EC probes
- Designing fail-safe control (safe state = aeration on)
- Proportional dosing control that avoids overshoot in a slow system
- Understanding the nitrogen cycle and fish tolerances
- Relay/pump control with interlocks and alarm escalation
Bill of Materials
Every part below is commonly available from Indian and international hobby-electronics suppliers. Prices are indicative 2026 retail figures in Indian rupees and will drift β treat them as a budgeting guide, not a quotation.
| Component | Key specification | Qty | Approx. cost |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) Wi-Fi transmit bursts peak near 500 mA β size the regulator accordingly. | Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18Γ 12-bit ADC, 2Γ 8-bit DAC | 1 | βΉ450 |
| Dissolved-oxygen probe (galvanic) The membrane and electrolyte are consumables β budget a replacement every 6β12 months. | 0β20 mg/L, Β±0.3 mg/L, galvanic, no warm-up, membrane cap consumable | 1 | βΉ5,800 |
| Analogue pH sensor kit (E-201-C probe + BNC board) Two-point calibrate with pH 4.00 and pH 6.86 buffers; store the probe wet. | pH 0β14, Β±0.1 pH at 25 Β°C, 5β60 Β°C, response < 1 min | 1 | βΉ2,400 |
| DS18B20 waterproof temperature probe Dozens can share one GPIO β you address them by ROM code. | β55 to +125 Β°C, Β±0.5 Β°C from β10 to +85 Β°C, 9β12-bit resolution, unique 64-bit ROM ID | 1 | βΉ160 |
| Analogue TDS / conductivity probe Conductivity rises about 2 % per Β°C β always temperature-compensate the reading. | 0β1000 ppm, Β±10 % F.S., 0β2.3 V analogue, waterproof probe | 1 | βΉ780 |
| 4-channel opto-isolated relay board All four coils energised draw ~280 mA β do not power from the MCU 5 V pin. | 4 Γ SPDT, 10 A @ 250 VAC, active-low inputs, LED per channel | 1 | βΉ280 |
| 5 V submersible mini water pump Never run it dry; add a flyback diode across the motor terminals. | 80β120 L/h, 0.4β1.5 m head, 5 V DC, 5 mm outlet | 1 | βΉ160 |
| Air pump + air stones (aeration) Consider a second, independently-powered backup aerator | Sized to the tank volume/stocking; the primary life-support actuator | 1 | βΉ900 |
| pH dosing pumps (acid/base) Peristaltic for precise small doses | Two peristaltic pumps for slow, metered pH correction | 2 | βΉ1,400 |
| Lab pH / DO calibration standards Consumable β recalibrate regularly | pH 4/7/10 buffers; DO zero + saturation calibration | 1 | βΉ500 |
| Backup aerator on separate power The most important safety item in the build | Battery/UPS-backed air pump as independent fail-safe | 1 | βΉ800 |
Estimated total: βΉ13,630, excluding tools, shipping and consumables.
Tools and consumables
- Soldering iron (temperature controlled, 350 Β°C) with 0.8 mm 60/40 or lead-free solder
- Digital multimeter β continuity, DC volts and current ranges
- Wire strippers, flush cutters and a small set of precision screwdrivers
- Heat-shrink tubing and a heat gun (or a lighter, carefully)
- A laptop with a USB port and the toolchain listed above
Hardware Specifications
| Part | Specification | Supply | Interface | Reference |
|---|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18Γ 12-bit ADC, 2Γ 8-bit DAC | 3.3 V logic / 5 V USB | UART, SPI, IΒ²C, IΒ²S, CAN, PWM | Datasheet |
| Dissolved-oxygen probe (galvanic) | 0β20 mg/L, Β±0.3 mg/L, galvanic, no warm-up, membrane cap consumable | 3.3β5 V | Analogue | Datasheet |
| Analogue pH sensor kit (E-201-C probe + BNC board) | pH 0β14, Β±0.1 pH at 25 Β°C, 5β60 Β°C, response < 1 min | 5 V | Analogue (offset trimmer) | Datasheet |
| DS18B20 waterproof temperature probe | β55 to +125 Β°C, Β±0.5 Β°C from β10 to +85 Β°C, 9β12-bit resolution, unique 64-bit ROM ID | 3.0β5.5 V | 1-Wire (multi-drop) | Datasheet |
| Analogue TDS / conductivity probe | 0β1000 ppm, Β±10 % F.S., 0β2.3 V analogue, waterproof probe | 3.3β5.5 V | Analogue | Datasheet |
| 4-channel opto-isolated relay board | 4 Γ SPDT, 10 A @ 250 VAC, active-low inputs, LED per channel | 5 V coil | 4Γ digital | Datasheet |
| 5 V submersible mini water pump | 80β120 L/h, 0.4β1.5 m head, 5 V DC, 5 mm outlet | 3β6 V | Relay / MOSFET | Datasheet |
Consolidated electrical and interface specifications for every active part in the build.
Power Budget & Supply Sizing
Add up the typical active current of every part, then size the supply with at least 50 % headroom so transmit bursts and motor inrush never brown out the controller.
| Load | Supply rail | Typical current (mA) | Notes |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | 3.3 V logic / 5 V USB | 160 | Wi-Fi transmit bursts peak near 500 mA β size the regulator accordingly. |
| Dissolved-oxygen probe (galvanic) | 3.3β5 V | 5 | The membrane and electrolyte are consumables β budget a replacement every 6β12 months. |
| Analogue pH sensor kit (E-201-C probe + BNC board) | 5 V | 8 | Two-point calibrate with pH 4.00 and pH 6.86 buffers; store the probe wet. |
| DS18B20 waterproof temperature probe | 3.0β5.5 V | 1.5 | Dozens can share one GPIO β you address them by ROM code. |
| Analogue TDS / conductivity probe | 3.3β5.5 V | 4 | Conductivity rises about 2 % per Β°C β always temperature-compensate the reading. |
| 4-channel opto-isolated relay board | 5 V coil | 280 | All four coils energised draw ~280 mA β do not power from the MCU 5 V pin. |
| 5 V submersible mini water pump | 3β6 V | 220 | Never run it dry; add a flyback diode across the motor terminals. |
Summed typical draw is 678.5 mA. With a 1.5Γ design margin the supply should deliver at least 1100 mA continuously at the stated rail voltage.
Software Requirements & Development Environment
Reference toolchain: Arduino IDE 2.3.x with the ESP32 board package 3.x (or PlatformIO on VS Code). Anything newer normally works; anything older may lack the board definitions used here.
- Install the Arduino IDE 2.3.x (or PlatformIO if you prefer a real editor and dependency locking).
- Add
https://espressif.github.io/arduino-esp32/package_esp32_index.jsonunder File β Preferences β Additional Board Manager URLs, then install esp32 from the Boards Manager. - Set the correct port under Tools β Port. On Linux add yourself to the
dialoutgroup:sudo usermod -aG dialout $USERand log out and back in. - Open the Serial Monitor at 115200 baud β every sketch here logs its state there.
- Keep File β Preferences β Show verbose output during: compilation switched on while you are debugging build errors.
Required libraries
| Library | Why it is needed | Install |
|---|---|---|
| WiFi (ESP32 core) bundled | Station/AP connection management for the ESP32. | Bundled with the ESP32 Arduino core |
| PubSubClient 2.8 | Lightweight MQTT 3.1.1 client for constrained devices. | Library Manager β "PubSubClient" by Nick O'Leary |
| OneWire + DallasTemperature 2.3.x / 3.9.x | Bus enumeration and conversion commands for DS18B20 probes. | Library Manager β "DallasTemperature" (pulls OneWire) |
| Adafruit Unified Sensor 1.1.x | Common sensor event abstraction; a dependency of most Adafruit drivers. | Library Manager β "Adafruit Unified Sensor" |
| PID_v1 1.2.1 | Proportional-integral-derivative controller with anti-windup. | Library Manager β "PID" by Brett Beauregard |
| ArduinoJson 7.x | Zero-allocation JSON serialisation and parsing. | Library Manager β "ArduinoJson" by Benoit Blanchon |
| Preferences (NVS) bundled | Wear-levelled key/value storage in ESP32 flash for settings. | Bundled with the ESP32 core |
| NTPClient / configTime bundled | Wall-clock time from an NTP server for timestamping. | Bundled (`configTime()` on ESP32) |
Block Diagram
The block diagram shows the functional decomposition of the system β what senses, what decides, what acts, and where the data ends up.
Circuit Diagram & Wiring
Every signal line in the build is shown below, followed by a pin-by-pin connection table you can work through with a multimeter in hand.
| Peripheral | Peripheral pin | Controller pin | Signal |
|---|---|---|---|
| DO probe (analog) | AOUT | GPIO 34 (ADC) | Dissolved oxygen |
| pH probe (analog) | AOUT | GPIO 35 (ADC) | pH via BNC amp board |
| DS18B20 | DQ | GPIO 4 | Water temperature (compensates DO/pH) |
| TDS/EC probe | AOUT | GPIO 32 (ADC) | Electrical conductivity / nutrient load |
| Relay ch1 | IN | GPIO 26 | Aerator (fail-safe ON) |
| Relay ch2 | IN | GPIO 25 | Circulation pump |
| Dose pump β acid | IN | GPIO 27 | pH-down peristaltic |
| Dose pump β base | IN | GPIO 14 | pH-up peristaltic |
Wire one row at a time and tick it off β most "it does not work" reports trace back to a single swapped pair.
Wiring explanation
- Wire the aerator relay so the safe state is energised/aerating: choose the relay sense and default output so a reset, brown-out or crash leaves the aerator running, and back it up with an independently-powered aerator that the controller cannot switch off.
- Keep the pH probe on a proper high-impedance BNC amplifier board; the raw electrode signal cannot drive an ADC directly and is easily corrupted by noise.
- Give the analogue probes a clean, stable reference and keep their grounds away from the pump/relay switching currents that inject noise.
- Use peristaltic pumps for pH dosing so a dose is a precise, small, metered volume β never a valve that could dump.
- Place the DS18B20 in the water flow so its temperature genuinely represents the water the DO and pH probes see, since both readings are temperature-dependent.
System Architecture
Read the stack from the bottom up: physical hardware, the firmware that drives it, the transport that moves data off the device, and the software a human actually looks at.
Working Principle
The controller's priorities are strictly ordered, and dissolved oxygen sits at the top because it is the only variable that can kill the whole system within minutes. Fish extract oxygen from water across their gills; the nitrifying bacteria that keep ammonia from poisoning those same fish are aerobic and stop working without oxygen. Warm water holds less dissolved oxygen than cool water, and warm nights combine low solubility with high biological respiration, so the classic catastrophe is a pre-dawn DO crash. The design answer is to treat aeration as life-support: hold DO above a hard floor at all costs, and arrange the electronics so that the failure mode β a crash, a brown-out, a lost network β leaves the aerator running. An independently-powered backup aerator the controller cannot switch off is the belt to that braces.
pH is managed with a completely different temperament: slowly. The fish, the bacteria and the plants each prefer a slightly different pH, so the target is a compromise near neutral that keeps the fish and bacteria safe while the plants tolerate it. The danger with pH is not just the wrong value but the wrong rate of change β a rapid swing shocks fish and stalls the bacteria more than a modest steady offset would. So the controller doses in small, metered amounts with a long wait between doses, using proportional control that eases toward the target and deliberately under-corrects rather than risk overshoot. In a large, slow water volume, patience is safety.
Water temperature is monitored rather than aggressively controlled, but it is not passive information: it sets the oxygen-carrying capacity of the water (so the DO floor and aeration expectations are temperature-aware), it governs how fast the bacteria process ammonia, and it bounds what the fish and plants can tolerate. Most systems can add heat but cannot easily remove it, so the controller's temperature role is mainly to warn β a rising temperature is also a falling oxygen ceiling, and the two alarms reinforce each other.
EC/TDS closes the picture as a proxy for nutrient concentration and dilution. It does not directly measure the toxic ammonia and nitrite β those need test kits or dedicated sensors β but its trend reveals a lot: a steady rise tracks nutrient accumulation and evaporation, a sudden drop flags a water change or top-up, and unexpected jumps can hint at a problem. Because ammonia and nitrite are the acutely toxic species and are hard to measure cheaply online, the controller's water-chemistry alarms are framed around protecting the bacteria that destroy them: keep oxygen, pH and temperature in range and the invisible nitrogen-cycle workforce keeps the toxins low for you. The controller is, in the end, a caretaker of that workforce as much as of the water.
The maths behind it
Temperature-dependent oxygen saturation
The maximum DO water can hold falls as it warms:
DO_sat(T) decreases roughly from ~9 mg/L at 20 Β°C
to ~7 mg/L at 30 Β°C (freshwater, sea level).
Express health as % of saturation, not just mg/L:
DO_pct = DO_meas / DO_sat(T) Γ 100
A warm tank can be near saturation yet still low in absolute
mg/L β which is why the floor is set in mg/L for the fish.
pH probe (Nernstian) with temperature
The electrode voltage is temperature-sensitive:
pH = 7 + (V_probe β V_offset) / (S Β· kΒ·T)
S is the slope from 2-/3-point buffer calibration,
kΒ·T is the Nernst temperature term (β 59 mV/pH at 25 Β°C).
Always calibrate against pH 4/7/(10) buffers and apply the
temperature correction, or dosing acts on a wrong number.
Proportional, rate-limited pH dosing
error = pH β pH_target
dose_ml = clamp(Kp Β· error, 0, dose_max) // small cap
Wait t_settle (minutes) after each dose before re-measuring,
because the tank mixes slowly. Never chase the target with
back-to-back doses β that is how pH overshoots and shocks
the fish. Under-correct and repeat.
Program Flowchart
The firmware is a single cooperative loop. Nothing blocks for long, so networking, sensing and the user interface all stay responsive.
Assembly Instructions
Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.
Wire aeration as fail-safe life-support
Connect the primary aerator through a relay arranged so the default/failed state is aerating, and add a second aerator on independent (battery/UPS) power that the controller cannot turn off.
Verify by cutting controller power that at least one aerator keeps running.
Install and amplify the probes
Mount the DO, pH, EC and temperature probes in flowing water where they see representative conditions. Put the pH electrode on its BNC amplifier board and keep probe grounds away from pump/relay noise.
Fit the two peristaltic dosing pumps with their intakes in the acid and base reservoirs and outlets into a well-mixed part of the flow.
Set up dosing reservoirs and circulation
Place small, clearly-labelled acid and base reservoirs and prime the dosing lines. Ensure the circulation pump keeps the water mixed so a dose disperses rather than pooling at the probe.
Step-by-Step Implementation Guide
Work through these in order. Each step ends in something you can observe, so a failure is always localised to the step you just finished.
Calibrate every probe
Calibrate pH against 4/7/(10) buffers, DO against a zero solution and air-saturated water, and EC against a standard solution. Store the calibration constants and the date.
Implement the ordered control logic
Each cycle, evaluate DO first (aerate/alarm), then pH (slow dose within limits), then log temperature and EC trends. Never let pH or EC logic override the DO floor.
cppaquaponics-control.ino// Priority-ordered control: dissolved oxygen is life-support and wins. void controlCycle(float doMgL, float pH, float tempC, float ec, uint32_t now) { // 1) Dissolved oxygen β hard floor, fail-safe toward aeration. if (doMgL < DO_FLOOR) { setAerator(true); if (doMgL < DO_CRIT) escalate("DO CRITICAL", doMgL); // wake a human } else if (doMgL > DO_HIGH_OFF) { // Only ever ease aeration; a schedule keeps a baseline running. setAerator(scheduledAeration(now)); } // 2) pH β slow, rate-limited proportional dosing, DO permitting. static uint32_t lastDose = 0; if (now - lastDose > SETTLE_MS) { float err = pH - PH_TARGET; if (fabsf(err) > PH_DEADBAND) { float ml = fminf(fabsf(err) * KP_PH, DOSE_MAX_ML); // small, capped if (err > 0) doseBase(0); // pH high? add... if (err > 0) doseAcid(ml); // acid to lower pH else doseBase(ml); // base to raise pH lastDose = now; logDose(err, ml); } } // 3) Temperature + EC β monitor, warn, adjust expectations. if (tempC > TEMP_WARN) warn("Water warm β oxygen ceiling low", tempC); logTrends(doMgL, pH, tempC, ec); }if (doMgL < DO_FLOOR)Dissolved oxygen is checked first and unconditionally turns the aerator on; nothing later in the cycle can countermand it.if (doMgL < DO_CRIT) escalateBelow a critical level the controller does not just aerate β it wakes a human, because a DO crash is a minutes-not-hours emergency.if (now - lastDose > SETTLE_MS)pH is only re-dosed after a settling wait, because a slow-mixing tank must be given time to respond before correcting again β this is what prevents overshoot.float ml = fminf(fabsf(err) * KP_PH, DOSE_MAX_ML)Each dose is proportional to the error but hard-capped small, so the controller deliberately under-corrects and eases toward the target rather than shocking the fish.if (tempC > TEMP_WARN)Temperature is treated as a warning that the oxygen ceiling is dropping, reinforcing the DO logic rather than driving its own aggressive actuator.Log, report and escalate
Log all four variables and every dose; report trends to the dashboard; and route DO-critical alarms to immediate escalation (push/SMS/siren) separate from routine notifications.
Complete Source Code
The listing below is complete and compiles as written β there are no elided sections. Read the annotations under each block before you upload it.
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Aquaponics Controller β ESP32, DO/pH/temp/EC, fail-safe aeration
Priority-ordered life-support: dissolved oxygen held above a hard
floor (fail-safe toward aeration), pH corrected by slow rate-limited
dosing, temperature and EC monitored. DO-critical alarms escalate.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
#include <WiFi.h>
#include <PubSubClient.h>
#include <OneWire.h>
#include <DallasTemperature.h>
#include <Preferences.h>
#include <math.h>
#define PIN_DO 34
#define PIN_PH 35
#define PIN_EC 32
#define OW_PIN 4
#define PIN_AER 26 // aerator relay β WIRED FAIL-SAFE ON
#define PIN_CIRC 25 // circulation pump
#define PIN_ACID 27 // peristaltic pH-down
#define PIN_BASE 14 // peristaltic pH-up
#define DO_FLOOR 5.0f // mg/L β keep above this for fish + bacteria
#define DO_CRIT 3.0f // mg/L β emergency escalation
#define DO_HIGH_OFF 7.5f // mg/L β may ease aeration to schedule
#define PH_TARGET 6.8f // fish/bacteria/plant compromise
#define PH_DEADBAND 0.2f
#define KP_PH 6.0f // ml per pH unit of error (small)
#define DOSE_MAX_ML 5.0f // hard cap per dose
#define SETTLE_MS 600000UL // 10 min between doses (slow tank)
#define TEMP_WARN 30.0f
#define CYCLE_MS 5000UL
OneWire ow(OW_PIN);
DallasTemperature water(&ow);
Preferences prefs;
WiFiClient net;
PubSubClient mqtt(net);
// Calibration constants loaded from flash.
float PH_SLOPE, PH_OFFSET, DO_CAL, EC_CAL;
uint32_t lastCycle = 0, lastDose = 0;
float doseMlPerMs; // peristaltic pump flow rate
/* ββ probe reads (calibrated, temperature-compensated) βββββββββ */
float readpH(float tempC) {
int raw = 0; for (int i = 0; i < 32; i++) raw += analogRead(PIN_PH);
float v = (raw / 32.0f) / 4095.0f * 3.3f;
float tc = (tempC + 273.15f) / 298.15f; // Nernst temp factor
return 7.0f + (PH_OFFSET - v) / (PH_SLOPE * tc);
}
float readDO(float tempC) {
int raw = 0; for (int i = 0; i < 32; i++) raw += analogRead(PIN_DO);
float v = (raw / 32.0f) / 4095.0f * 3.3f;
// DO_CAL maps voltageβmg/L; result scaled by saturation vs temperature.
return v * DO_CAL * (1.0f - 0.023f * (tempC - 20.0f));
}
float readEC() {
int raw = 0; for (int i = 0; i < 32; i++) raw += analogRead(PIN_EC);
return (raw / 32.0f) / 4095.0f * 3.3f * EC_CAL;
}
/* ββ actuators βββββββββββββββββββββββββββββββββββββββββββββββββ */
void setAerator(bool on) { digitalWrite(PIN_AER, on ? HIGH : LOW); }
void dose(uint8_t pin, float ml) {
uint32_t t = (uint32_t)(ml / doseMlPerMs);
digitalWrite(pin, HIGH); delay(t); digitalWrite(pin, LOW);
}
bool scheduledAeration(uint32_t now) { return true; } // baseline: always on
void escalate(const char *why, float v) {
char m[120];
snprintf(m, sizeof m, "{\"crit\":\"%s\",\"val\":%.2f}", why, v);
mqtt.publish("aqua/1/critical", m); // separate, escalated topic
}
void report(float doMgL, float pH, float t, float ec, bool aer) {
char b[220];
snprintf(b, sizeof b,
"{\"do\":%.2f,\"ph\":%.2f,\"temp\":%.1f,\"ec\":%.0f,"
"\"aer\":%d,\"do_floor\":%.1f}",
doMgL, pH, t, ec, aer ? 1 : 0, DO_FLOOR);
mqtt.publish("aqua/1/state", b);
}
void loadCal() {
prefs.begin("aqua", true);
PH_SLOPE = prefs.getFloat("phS", 0.18f);
PH_OFFSET = prefs.getFloat("phO", 1.65f);
DO_CAL = prefs.getFloat("doC", 3.0f);
EC_CAL = prefs.getFloat("ecC", 1000.0f);
doseMlPerMs = prefs.getFloat("dose", 0.001f);
prefs.end();
}
void setup() {
Serial.begin(115200);
pinMode(PIN_AER, OUTPUT); pinMode(PIN_CIRC, OUTPUT);
pinMode(PIN_ACID, OUTPUT); pinMode(PIN_BASE, OUTPUT);
setAerator(true); // start aerating immediately
digitalWrite(PIN_CIRC, HIGH); // circulation always on
water.begin();
loadCal();
WiFi.begin(WIFI_SSID, WIFI_PASS);
mqtt.setServer(MQTT_HOST, 1883);
}
void loop() {
if (!mqtt.connected() && WiFi.status() == WL_CONNECTED) mqtt.connect("aqua-1");
mqtt.loop();
uint32_t now = millis();
if (now - lastCycle < CYCLE_MS) return;
lastCycle = now;
water.requestTemperatures();
float tempC = water.getTempCByIndex(0);
float pH = readpH(tempC);
float doMgL = readDO(tempC);
float ec = readEC();
// 1) Dissolved oxygen β life-support, wins over everything.
bool aer = true;
if (doMgL < DO_FLOOR) {
setAerator(true);
if (doMgL < DO_CRIT) escalate("DO CRITICAL", doMgL);
} else if (doMgL > DO_HIGH_OFF) {
aer = scheduledAeration(now); // still defaults on here
setAerator(aer);
}
// 2) pH β slow, rate-limited, capped dosing.
if (now - lastDose > SETTLE_MS) {
float err = pH - PH_TARGET;
if (fabsf(err) > PH_DEADBAND) {
float ml = fminf(fabsf(err) * KP_PH, DOSE_MAX_ML);
if (err > 0) dose(PIN_ACID, ml); // pH too high β add acid
else dose(PIN_BASE, ml); // pH too low β add base
lastDose = now;
}
}
// 3) Temperature β warn (oxygen ceiling falls as it warms).
if (tempC > TEMP_WARN)
mqtt.publish("aqua/1/warn", "water warm; DO ceiling low");
report(doMgL, pH, tempC, ec, aer);
}
Configuration & Calibration
Configuration steps
- Set DO_FLOOR and DO_CRIT to your fish species' oxygen needs (many need β₯ 5 mg/L; sensitive species higher).
- Set PH_TARGET to the compromise for your fish, bacteria and crops (commonly ~6.8β7.0), and keep KP_PH and DOSE_MAX_ML small.
- Load probe calibration constants (pH slope/offset, DO and EC) and record the calibration dates.
- Configure the settle time between doses to your tank volume/mixing, and route DO-critical alarms to real escalation (SMS/siren).
Calibration procedure
An uncalibrated sensor produces confident, precise, wrong numbers. Do this once per physical unit and record the constants.
pH
Two- or three-point calibrate against pH 4/7/(10) buffers at a known temperature; store slope and offset. Re-check weekly at first.
Dissolved oxygen
Zero the probe in a zero-oxygen solution and set the span in air-saturated water at a known temperature; verify the temperature compensation.
EC and dosing pumps
Calibrate EC against a standard solution. Measure each peristaltic pump's ml-per-second by timed dispense so a commanded dose is an accurate volume.
Network Architecture & Connectivity
Communication protocol
State publishes every few seconds; DO-critical events publish immediately on a dedicated topic that the escalation server treats as urgent. All life-critical control (aeration floor, fail-safe) runs locally and never depends on the network.
| Topic / endpoint | Direction | Payload |
|---|---|---|
aqua/1/state | node β broker | DO, pH, temp, EC, aerator state |
aqua/1/critical | node β broker | DO-critical emergency (escalated) |
aqua/1/warn | node β broker | temperature / drift warnings |
Message contract between the device and the broker.
Cloud platform configuration
A broker feeds a dashboard that trends all four variables and a separate escalation path for DO-critical alarms; the cloud is for visibility and alerting, never for holding the oxygen floor, which stays entirely local.
Dashboard setup
A prominent dissolved-oxygen gauge against its floor, plus pH-against-target, water temperature and EC trends and a dosing history β designed so the one life-critical number is impossible to miss.
Mobile app integration
Routine warnings notify; a DO-critical alarm escalates hard (push, then SMS/call) because it is a minutes-scale emergency.
Security considerations
- Keep all life-support control local and independent of the network and cloud.
- Authenticate any remote command; never allow a remote command to disable aeration.
- Alarm on controller silence so a dead node is noticed before the fish are.
Electronics: PCB, Ratings & Pin Detail
PCB information
- Keep the high-impedance pH input isolated and guarded on the board; ground loops and noise from pump/relay switching corrupt it easily.
- Separate the low-voltage analogue front end from the relay/pump switching section, with a single clean ground reference for the ADC.
- Fuse the pump and aerator outputs and use relays/SSRs rated well above the actuator currents.
Power calculations
Aerator sizing (rule of thumb)
Oxygen demand rises with fish load and temperature.
Size aeration so DO stays > floor at the WARM-night worst case,
not the cool-day average. Include margin and a backup unit.
The aerator must cover peak biological oxygen demand on the hottest night, with the water least able to hold oxygen β the moment the system is most likely to fail.
Dose volume per correction
ml = flow_rate(ml/s) Γ on_time(s)
Cap ml so one dose shifts pH only a fraction of a unit
in the full tank volume β small relative to buffering.
A dose should nudge, not shove: sized small against the tank's volume and buffering so it cannot overshoot before the next settle.
| Parameter | Rating | Design margin |
|---|---|---|
| Aerator relay/SSR | β₯ 2Γ aerator current | Runs near-continuously; derate generously |
| Peristaltic dose pumps | Per pump spec | Short bursts; protect against run-dry |
| pH amp input | High-impedance, guarded | Never load the electrode; shield the lead |
| Controller supply | Stable 5 V + backup | Loss must leave aeration running |
Never operate a part beyond about 70 % of its absolute maximum rating.
Pin diagram
| Pin | Function | Note |
|---|---|---|
GPIO 34/35/32 | DO / pH / EC analogue in | ADC; average many samples; clean ground |
GPIO 4 | DS18B20 water temp | 1-Wire; compensates DO and pH |
GPIO 26 | Aerator relay | Wired fail-safe ON |
GPIO 27/14 | Acid / base dose pumps | Peristaltic; short metered pulses |
Testing Procedure & Expected Output
Test from the bottom up. Confirm power, then each sensor in isolation, then the integrated loop β the first failing step tells you exactly where to look.
| Test | What you should see |
|---|---|
| Cut controller power with fish present (dry-run/empty first) | A backup aerator keeps running; the system stays oxygenated |
| Lower DO below the floor (simulate) | Aerator forced on; below critical, escalation alarm fires |
| Offset pH above target | One small acid dose, then a settle wait before any further dose β no chasing |
| Warm the water past the temp-warn level | Warning published; DO logic already holding the floor |
| Change/top-up water | EC trend steps; logged, no spurious dosing |
| Verify a dose volume against the pump calibration | Dispensed ml matches the commanded ml within tolerance |
Bench-test checklist. If a row fails, stop and fix it before moving on.
Expected output
The dashboard shows live DO (against its floor), pH (against target), water temperature and EC, plus a dosing log and a prominent DO status indicator.
{
"do": 6.4,
"ph": 6.9,
"temp": 26.3,
"ec": 1180,
"aer": 1,
"do_floor": 5.0
}
Here DO sits safely above its 5 mg/L floor with the aerator on, pH is near the 6.8 target, and temperature and EC are in a healthy range β a balanced system, with the DO indicator the one to watch overnight.
Troubleshooting: Common Errors & Fixes
Performance Optimisation
- A few-second control cycle is fast enough for water chemistry, which changes slowly, while keeping ADC and dosing work light.
- Average many ADC samples per probe read; a single sample is far too noisy to drive dosing.
- Rate-limit dosing with a settle timer so the slow tank is never over-corrected.
- Keep the life-critical DO loop entirely local so latency or an outage never delays aeration.
- Replace every
delay()with amillis()comparison β blocking delays are the single most common cause of dropped readings. - Sample sensors on a fixed cadence and publish on a slower one; you almost never need to transmit at the sampling rate.
- Move networking into its own FreeRTOS task so a slow DNS lookup cannot stall the control loop.
- Use
uint8_t/uint16_twhere the range allows; on an 8-bit AVR a 32-bit add costs four times as much. - Batch several samples into one MQTT publish. Radio time, not CPU time, dominates the energy budget.
- Set the MQTT keep-alive to a value that matches your reporting interval so the broker does not churn reconnections.
- For battery builds use deep sleep between samples: an ESP32 drops from ~160 mA awake to about 10 Β΅A asleep, which is the difference between days and months of runtime.
Safety Precautions
- Dissolved oxygen is life-critical: design so every fault leaves aeration running, and always keep an independently-powered backup aerator.
- Handle pH-adjustment chemicals safely; label and separate acid and base reservoirs and lines.
- Never dose pH quickly β a rapid swing can be more harmful than the deviation being corrected.
- Keep a human in the loop for anything beyond aeration and gentle dosing; a living system cannot be rebooted.
- Mains voltage kills. Anything on the load side of the relay is at 230 V. Do not work on a powered circuit, and never leave exposed mains wiring on a bench where someone could touch it.
- Keep at least 6 mm of creepage between the mains and low-voltage sides of any board you make, and never route mains tracks under the microcontroller.
- Have a qualified electrician do the final installation into a consumer unit or wall fitting. In most jurisdictions this is a legal requirement, not a suggestion.
- Fit an RCD/RCBO upstream and fuse the load appropriately for its rating.
- Moving parts pinch. Keep fingers, cables and hair out of gear trains and wheels, and always test motion with the drivetrain unloaded and the robot on blocks first.
- Motors are inductive β always fit a flyback diode across a DC coil, or use a driver that already has one, or the back-EMF spike will destroy your GPIO.
- Water and electronics: mount all boards above the maximum possible water line, use drip loops on every cable, and pressure-test plumbing before wiring anything up.
- Wear eye protection when soldering or cutting, and solder in a ventilated space β rosin flux fumes are a respiratory irritant.
- Power the circuit through a bench supply with a current limit while you are testing. A 300 mA limit turns a wiring mistake into a beep instead of a dead board.
- Disconnect power before changing any wiring. Hot-plugging a sensor onto a live bus is the fastest way to lose a controller.
Maintenance
- Recalibrate pH, DO and EC probes on a schedule and log the dates; replace ageing electrodes.
- Clean probe surfaces of biofilm, which slowly falsifies readings.
- Refill and prime dosing reservoirs; verify pump flow rates so doses stay accurate.
- Regularly test the aerator fail-safe and backup, and cross-check ammonia/nitrite with test kits.
- Re-check every screw terminal and header after the first week β thermal cycling loosens connections that felt tight on day one.
- Clean the sensing element on a schedule. Optical and electrochemical sensors foul, and a fouled sensor reports plausible nonsense rather than failing outright.
- Keep the broker and dashboard containers patched, and rotate device credentials at least once a year.
- Recalibrate at the interval given in the calibration section, and keep the constants in a text file next to the firmware β not only in flash.
- Keep a short logbook of firmware versions and what changed. Six months later you will not remember why that constant is 1.083.
Future Improvements & Upgrades
A working v1 is a platform, not a finish line. These are the upgrades that add the most capability for the least rework.
- Add online ammonia/nitrate sensing to close the loop on the nitrogen cycle directly.
- Add dosing for plant nutrients (iron, potassium) that aquaponic systems often lack.
- Model DO demand from temperature and feeding to pre-emptively aerate before a night-time crash.
- Integrate fish-feeding automation with water-quality feedback to avoid overfeeding, the root of many problems.
- Design a proper PCB. Once the breadboard version has run for a month, moving to a two-layer board removes the intermittent-contact failures that dominate prototype faults.
- Add over-the-air firmware updates so you never have to physically reach a deployed node again.
- Add persistent local storage (microSD or the on-chip flash) so a network outage does not create a hole in your data.
- Move configuration out of the source: a captive-portal setup page or a JSON config file makes the build reusable without a recompile.
- Add a battery and solar option so the unit survives a power cut and can be sited away from a socket.
- Write a small test harness that feeds synthetic sensor values through the decision logic, so you can validate thresholds without physically triggering the event.
Frequently Asked Questions
References & Learning Resources
These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.