Siddhant Kumar
Project 038 Β· Agriculture

Aquaponics Controller.

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.

Advanced 14–20 hours 38 min read pHAutomationSensors
Jump to source Bill of materials
Aquaponics Controller β€” reference build illustration MCU VCC Β· GND Β· SIG Β· NC
Difficulty
Advanced
Build time
14–20 hours
Indicative cost
β‚Ή5,500 – β‚Ή7,500
Platform
ESP32 DevKit V1 (ESP-WROOM-32)
Category
Agriculture
Last updated
28 July 2026
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.

The interior of a commercial greenhouse with rows of plants
An aquaponic system grows fish and plants together β€” the controller holds the water in the band where both, and the bacteria between them, survive. Photograph sourced from Wikimedia Commons β€” Greenhouse interior.jpg. Reused under the licence stated on that page; please check it before republishing.

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

SettingHow it is used
Backyard / educational aquaponicsA 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 farmsLarger 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 systemsControlled 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

AttributeValue
Difficulty levelAdvanced
Estimated completion time14–20 hours
Indicative build costβ‚Ή5,500 – β‚Ή7,500
Primary disciplineAgriculture
Reference platformESP32 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.

ComponentKey specificationQtyApprox. 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 DAC1β‚Ή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 consumable1β‚Ή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 min1β‚Ή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 ID1β‚Ή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 probe1β‚Ή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 channel1β‚Ή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 outlet1β‚Ή160
Air pump + air stones (aeration)
Consider a second, independently-powered backup aerator
Sized to the tank volume/stocking; the primary life-support actuator1β‚Ή900
pH dosing pumps (acid/base)
Peristaltic for precise small doses
Two peristaltic pumps for slow, metered pH correction2β‚Ή1,400
Lab pH / DO calibration standards
Consumable β€” recalibrate regularly
pH 4/7/10 buffers; DO zero + saturation calibration1β‚Ή500
Backup aerator on separate power
The most important safety item in the build
Battery/UPS-backed air pump as independent fail-safe1β‚Ή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

PartSpecificationSupplyInterfaceReference
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 DAC3.3 V logic / 5 V USBUART, SPI, IΒ²C, IΒ²S, CAN, PWMDatasheet
Dissolved-oxygen probe (galvanic)0–20 mg/L, Β±0.3 mg/L, galvanic, no warm-up, membrane cap consumable3.3–5 VAnalogueDatasheet
Analogue pH sensor kit (E-201-C probe + BNC board)pH 0–14, Β±0.1 pH at 25 Β°C, 5–60 Β°C, response < 1 min5 VAnalogue (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 ID3.0–5.5 V1-Wire (multi-drop)Datasheet
Analogue TDS / conductivity probe0–1000 ppm, Β±10 % F.S., 0–2.3 V analogue, waterproof probe3.3–5.5 VAnalogueDatasheet
4-channel opto-isolated relay board4 Γ— SPDT, 10 A @ 250 VAC, active-low inputs, LED per channel5 V coil4Γ— digitalDatasheet
5 V submersible mini water pump80–120 L/h, 0.4–1.5 m head, 5 V DC, 5 mm outlet3–6 VRelay / MOSFETDatasheet

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.

LoadSupply railTypical current (mA)Notes
ESP32 DevKit V1 (ESP-WROOM-32)3.3 V logic / 5 V USB160Wi-Fi transmit bursts peak near 500 mA β€” size the regulator accordingly.
Dissolved-oxygen probe (galvanic)3.3–5 V5The membrane and electrolyte are consumables β€” budget a replacement every 6–12 months.
Analogue pH sensor kit (E-201-C probe + BNC board)5 V8Two-point calibrate with pH 4.00 and pH 6.86 buffers; store the probe wet.
DS18B20 waterproof temperature probe3.0–5.5 V1.5Dozens can share one GPIO β€” you address them by ROM code.
Analogue TDS / conductivity probe3.3–5.5 V4Conductivity rises about 2 % per Β°C β€” always temperature-compensate the reading.
4-channel opto-isolated relay board5 V coil280All four coils energised draw ~280 mA β€” do not power from the MCU 5 V pin.
5 V submersible mini water pump3–6 V220Never 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.json under 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 dialout group: sudo usermod -aG dialout $USER and 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

LibraryWhy it is neededInstall
WiFi (ESP32 core) bundledStation/AP connection management for the ESP32.Bundled with the ESP32 Arduino core
PubSubClient 2.8Lightweight MQTT 3.1.1 client for constrained devices.Library Manager β†’ "PubSubClient" by Nick O'Leary
OneWire + DallasTemperature 2.3.x / 3.9.xBus enumeration and conversion commands for DS18B20 probes.Library Manager β†’ "DallasTemperature" (pulls OneWire)
Adafruit Unified Sensor 1.1.xCommon sensor event abstraction; a dependency of most Adafruit drivers.Library Manager β†’ "Adafruit Unified Sensor"
PID_v1 1.2.1Proportional-integral-derivative controller with anti-windup.Library Manager β†’ "PID" by Brett Beauregard
ArduinoJson 7.xZero-allocation JSON serialisation and parsing.Library Manager β†’ "ArduinoJson" by Benoit Blanchon
Preferences (NVS) bundledWear-levelled key/value storage in ESP32 flash for settings.Bundled with the ESP32 core
NTPClient / configTime bundledWall-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.

Aquaponics Controller β€” system block diagramFunctional block diagram of the Aquaponics Controller system. Water chemistryDissolved Oβ‚‚life-criticalpHslow dosingWater tempDS18B20EC/TDSnutrient loadDecideESP32fail-safe logicInterlocksDO floor firstActuateAeratorfail-safe ONCirculationpumpDoseacid / baseWatchDashboardtrendsEscalateDO alarmrightrightnone
Aquaponics Controller β€” system block diagram

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.

Aquaponics Controller β€” wiring schematicConnection schematic showing which controller pin drives each peripheral. Sensors / InputsControllerActuators / OutputsESP32 DevKit V1(ESP-WROOM-32)3.3 V logic / 5 V USBDO probe (analog)GPIO 34 (ADC)Dissolved oxygenpH probe (analog)GPIO 35 (ADC)pH via BNC amp boardDS18B20GPIO 4Water temperature(compensates DO/pH)TDS/EC probeGPIO 32 (ADC)Electricalconductivity /nutrient loadRelay ch1GPIO 26Aerator (fail-safeON)Relay ch2GPIO 25Circulation pumpDose pump β€” acidGPIO 27pH-down peristalticDose pump β€” baseGPIO 14pH-up peristaltic
Aquaponics Controller β€” wiring schematic
PeripheralPeripheral pinController pinSignal
DO probe (analog)AOUTGPIO 34 (ADC)Dissolved oxygen
pH probe (analog)AOUTGPIO 35 (ADC)pH via BNC amp board
DS18B20DQGPIO 4Water temperature (compensates DO/pH)
TDS/EC probeAOUTGPIO 32 (ADC)Electrical conductivity / nutrient load
Relay ch1INGPIO 26Aerator (fail-safe ON)
Relay ch2INGPIO 25Circulation pump
Dose pump β€” acidINGPIO 27pH-down peristaltic
Dose pump β€” baseINGPIO 14pH-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.
An ESP32 development board with the ESP-WROOM-32 module and USB connector
ESP32 module running the priority-ordered life-support logic, with dissolved oxygen always first. Photograph sourced from Wikimedia Commons β€” ESP32 Espressif ESP-WROOM-32 Dev Board.jpg. Reused under the licence stated on that page; please check it before republishing.

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.

Aquaponics Controller β€” architecture stackLayered architecture from hardware to user interface. Hardware layerESP32 DevKit V1 (ESP-WROOM-32) Β· Dissolved-oxygen probe (galvanic) Β·Analogue pH sensor kit (E-201-C probe + BNC board) Β· DS18B20 waterprooftemperature probeDriver layerwifi Β· pubsub Β· onewire Β· unifiedApplication logicsampling loop Β· filtering Β· thresholds Β· state machineTransport layerWi-Fi + MQTT; DO-critical on a separate escalated topic Β· TLS Β· retry andbackoffPresentation layerdashboard Β· mobile notifications Β· historical charts
Aquaponics Controller β€” architecture stack

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

plainTemperature-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

plainpH 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

plainProportional, 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.

Aquaponics Controller β€” firmware flowchartControl flow through the main program loop. Read DO, pH, temp, ECDO below floor?Aerator ON + critical alarmAerator per scheduleAerator ON + critical alarmAerator per schedulepH outside target band?Dose slowly toward targetHold dosingDose slowly toward targetHold dosingLog + report; wait interval
Aquaponics Controller β€” firmware flowchart

Assembly Instructions

Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.

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

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

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

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

  2. 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.
  3. 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.

cppaquaponics-controller.ino
/* ═══════════════════════════════════════════════════════════════
   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);
}
setAerator(true); // start aerating immediatelyThe very first thing setup does is turn aeration on and start circulation, so the system is life-supporting from the instant it powers up, before any sensor is even read.
float readpH(float tempC)The pH is computed from a calibrated slope/offset with the Nernst temperature factor applied, so dosing acts on a true, temperature-corrected pH rather than a raw voltage.
return v * DO_CAL * (1.0f - 0.023f * (tempC - 20.0f))The dissolved-oxygen reading is corrected for temperature, since the same probe voltage means different oxygen at different water temperatures.
if (doMgL < DO_CRIT) escalate("DO CRITICAL"A critical oxygen level publishes to a separate escalated topic β€” this is the alarm that must reach a person immediately, distinct from routine notifications.
if (now - lastDose > SETTLE_MS)Dosing is gated by a ten-minute settle timer and a hard per-dose cap, encoding the rule that pH must be nudged slowly in a big, slow tank β€” never chased.

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.

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

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

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

Aquaponics Controller β€” network topologyPath taken by telemetry from field node to end user. Edge nodesGatewayCloudClientsAqua controllerESP32Backup aeratorindependent powerWi-Fi 2.4 GHzRouterlocal-first controlMQTT 1883Broker + dashboardtrends + escalationDashboardDO/pH/temp/ECPhone/SMSDO alarms
Aquaponics Controller β€” network topology

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 / endpointDirectionPayload
aqua/1/statenode β†’ brokerDO, pH, temp, EC, aerator state
aqua/1/criticalnode β†’ brokerDO-critical emergency (escalated)
aqua/1/warnnode β†’ brokertemperature / 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)

plainAerator 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

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

ParameterRatingDesign margin
Aerator relay/SSRβ‰₯ 2Γ— aerator currentRuns near-continuously; derate generously
Peristaltic dose pumpsPer pump specShort bursts; protect against run-dry
pH amp inputHigh-impedance, guardedNever load the electrode; shield the lead
Controller supplyStable 5 V + backupLoss must leave aeration running

Never operate a part beyond about 70 % of its absolute maximum rating.

Pin diagram

PinFunctionNote
GPIO 34/35/32DO / pH / EC analogue inADC; average many samples; clean ground
GPIO 4DS18B20 water temp1-Wire; compensates DO and pH
GPIO 26Aerator relayWired fail-safe ON
GPIO 27/14Acid / base dose pumpsPeristaltic; 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.

TestWhat 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 targetOne small acid dose, then a settle wait before any further dose β€” no chasing
Warm the water past the temp-warn levelWarning published; DO logic already holding the floor
Change/top-up waterEC trend steps; logged, no spurious dosing
Verify a dose volume against the pump calibrationDispensed 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.

jsonaqua-state.json
{
  "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.

A Grafana time-series dashboard
A dashboard keeps the one life-critical number β€” dissolved oxygen β€” impossible to miss, alongside pH, temperature and EC trends. Photograph sourced from Wikimedia Commons β€” Grafana dashboard.png. Reused under the licence stated on that page; please check it before republishing.

Troubleshooting: Common Errors & Fixes

Fish gasping at the surface at dawn

Likely cause. Overnight DO crash β€” warm water, high respiration, weak aeration

Fix. Increase aeration capacity; raise the DO floor; ensure the fail-safe/backup aerator works; this is the classic emergency the design targets

pH oscillates or overshoots

Likely cause. Dosing too aggressively or too often

Fix. Reduce KP_PH and DOSE_MAX_ML; lengthen the settle time; the tank must be given time to mix

pH reading drifts / dosing on wrong number

Likely cause. Uncalibrated or ageing pH probe

Fix. Recalibrate against buffers; replace an old electrode; never dose on an unverified probe

DO reads implausibly high or low

Likely cause. Probe fouling or bad temperature compensation

Fix. Clean/service the DO probe; verify the water-temperature reading feeding the compensation

Bacteria seem to stall (ammonia/nitrite rise on test kit)

Likely cause. Low oxygen, pH out of range, or temperature too low for nitrification

Fix. Restore DO, pH and temperature to range and be patient; the bacteria recover slowly

The sketch will not upload β€” "Failed to connect" or "avrdude: stk500_recv()"

Likely cause. The bootloader is not being reached: wrong port, wrong board, a serial monitor holding the port open, or a USB cable that only carries power.

Fix. Close every serial monitor, confirm Tools β†’ Board and Port, and swap to a known data-capable USB cable. On an ESP32 hold BOOT while the IDE prints "Connecting…", then release. If a peripheral is wired to the UART pins (GPIO 1/3 on ESP32, D0/D1 on Uno) unplug it β€” it fights the programmer.

The board resets in a loop, or the serial monitor prints "Brownout detector was triggered"

Likely cause. The supply cannot deliver peak current. Wi-Fi transmit bursts, relay coils and servos all pull far more than their average draw.

Fix. Power peripherals from a separate regulated supply with a common ground rather than from the board 5 V pin. Add a 470–1000 Β΅F electrolytic capacitor across the supply near the load, and use a real power adapter rather than a laptop USB port.

Serial monitor shows garbage characters

Likely cause. Baud rate mismatch between Serial.begin() and the monitor, or a floating/shared UART line.

Fix. Set the monitor to 115200 to match the sketch. If it still garbles, the crystal or the USB bridge is being confused by noise β€” shorten the cable and keep motor wiring away from the USB lead.

Wi-Fi connects but MQTT never does (state -2)

Likely cause. Wrong broker address or port, a firewall in the way, or the broker requiring credentials the sketch is not sending.

Fix. Test from a laptop on the same network first: mosquitto_sub -h <broker> -t "#" -v. If that works, the problem is on the device β€” check the IP literal, port 1883 (or 8883 for TLS), and that client.setServer() runs before connect(). PubSubClient state codes are documented in its header.

Readings arrive for a while and then stop

Likely cause. The Wi-Fi or MQTT session dropped and the sketch never reconnects, or the broker dropped the client on keep-alive timeout.

Fix. Never assume the link stays up. Check WiFi.status() and client.connected() at the top of every loop and reconnect with exponential backoff. Add a watchdog so a wedged network stack reboots the device instead of going silent.

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 a millis() 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_t where 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

Why is dissolved oxygen treated so differently from pH?

Because a DO crash kills fish and stalls the bacteria within minutes, while pH problems act over hours to days. So DO is life-support β€” held above a hard floor, fail-safe on β€” and pH is nudged gently.

What does "fail-safe on" actually mean here?

The aerator is wired and the firmware arranged so that a crash, brown-out or lost network leaves it running, and a separate aerator on independent power the controller cannot switch off backs it up. Silence must never mean no oxygen.

Why dose pH so slowly instead of correcting fast?

A rapid pH swing shocks fish and the bacteria more than a modest steady offset. Small, capped doses with a settling wait ease the system to target without overshoot.

Does it measure ammonia and nitrite?

Not directly in this build β€” those are hard to sense cheaply online. Instead it protects the bacteria that destroy them by holding oxygen, pH and temperature in range, and you cross-check with test kits.

Can I run it purely from the cloud?

No. All life-critical control runs locally so an internet outage can never delay aeration. The cloud is only for trends and escalating alarms.

References & Learning Resources

These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.

  1. Aquaponics β€” principles and water chemistry (FAO)FAO
  2. Nitrogen cycle in aquaculture β€” overviewReference
  3. Dissolved oxygen and temperature solubilityReference
  4. pH measurement and Nernst equationReference
  5. Recirculating aquaculture systems (RAS) β€” overviewReference