It is now possible to calibrate the probe so that it reads the same as a calibrated EC wand.
You can do this in the main tank. Or you can use an external container with a measured table salt "standard" solution.

Also an HC-SR04 sonar device measures the level of the water in the feed tank. It is easy to remove the code if not needed.- see below.

The top of the white pipe will be in the air and a 4mm pipe will enter it above the water level to carry liquid from one of the ebb and flow pumps so that at least once a day the probe contents are flushed and should give a good EC reading. This should be visible on the EC/time of day graph.
I may experiment with a probe that fits to a hosepipe at both ends and carries the full flow of an ebb and flow pump. Perhaps the too and fro flow will keep it clean.

I also wonder if an open design with 4 brushes in a row might be OK so long as it stays in the same place in the tank. There is nothing absolute about the reading - it just needs to be proportional to the true EC.

Please read the comments in the code below.

This probe seems very stable - at least for salt in a bottle which is all the testing done so far.

I am wondering if the circuit is too complex - the real magic is the 4 probe design that separates current and voltage.

An ESP32 can provide 5 volts and 3.3 volts and can have 15 12bit A to D pins (0 to 4095)
If you set the fluid to the correct chosen EC value then calibrate the probe at that value you really only need to see if the value moves away from that value - no real accuracy needed.
The real need is probe stability with time and the 4 probe idea seems to provide that.

The ESP32 AtoD is not very linear and must not be trusted near 0 or 3.3 volts. But I guess this can be avoided.
see - https://randomnerdtutorials.com/esp32-adc-analog-read-arduino-ide/
ESP32 based 4 probe EC meter that can stay in the feed water 24/7.
A web page allows you to calibrate the probe against an EC wand that has been calibrated using a reference solution
Lesson one C code for Arduino IDE documented at http://www.sunspot.co.uk/ - with many thanks to :-
Rui Santos
Complete project details at https://RandomNerdTutorials.com/esp32-esp8266-input-data-html-form/
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
AND :-
Adafruit_ADS1015 ads; /* Use this for the 12-bit version
see https://randomnerdtutorials.com/esp32-save-data-permanently-preferences/
see https://www.luisllamas.es/en/esp32-preferences/
see https://www.masterblend.com/wp-content/uploads/2022/09/Masterblend-4-18-38-Mixing-Instructions-092922.pdf
see https://hydroponicseuro.com/mixing-instructions/
see https://randomnerdtutorials.com/esp32-esp8266-input-data-html-form/

definitions :-
setEC25 = the EC of the fluid as measured by a wand (or EC value of a calibtation fluid) at calibration time
cal_EC25 - multiply EC25 (the number from the electronics) by cal_EC25 to obtain the true EC25 - cal_EC25 is saved to ROM at calibration time.
realEC25now = EC25 just measured by the electronics in normal use multiplied by cal_EC25 retrieved from ROM

const int ledPin = 5; //3 volt drive pulse
const int LED_orange = 19; //LED to show the measurement period on a 'scope as a pulse - it also comes on and stays on to confirm ready for manual recalibration
const int CALswitch = 18; // push a button to take pin 18 low and trigger the manual recalibration sequence
int CAL_LED = 1; // 1 means button not pressed 0 means pressed so pin 18 is earthed LOW
// (if a fluid has just been measured to have a an EC of 1.5 (say) by a wand then CAL_LED set to 1.5 will make the 4 probe read like the wand)
//北北北北北北北北北北北北北北北北北北北 SONAR
int cycle = 0;
int trigger_pin = 25;
int echo_pin = 26;
int distance_cm = 0;
//北北北北北北北北北北北北北北北北北北北 SONAR
float setEC25;
float cal_EC25;
float EC25 = 0;
float realEC25now;
#include <Adafruit_ADS1X15.h>
Adafruit_ADS1115 ads; /* Use this for the 16-bit version -- default i2c address is 0x48 */
#include <Arduino.h>
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>

//--------------------------------------------------------------------forTemperature and SSD1306 OLED display
#include <OneWire.h>
#include <DallasTemperature.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define SCREEN_WIDTH 128 // OLED display width, in pixels
#define SCREEN_HEIGHT 64 // OLED display height, in pixels
//Declaration for an SSD1306 display connected to I2C (SDA, SCL pins)
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);

// the callibration multiplier has to be saved in the ESP32 ROM and be available after power off.

#define SENSOR_PIN 4 // ESP32 pin GPIO17 connected to one wire DS18B20 thermometer sensor DATA pin
OneWire oneWire(SENSOR_PIN); // setup a oneWire instance
DallasTemperature DS18B20(&oneWire); // pass oneWire to DallasTemperature library

String inputMessage;
AsyncWebServer server(80);

const char* ssid = "deco";
const char* password = "uu8diode";
const char* PARAM_INPUT_1 = "input1";
float temperature;
// =====================================================HTML web page to handle 1 input field (input1) START
const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE HTML><html><head>
<title>ESP Input Form</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<font size="5" face="arial" color="red">
<p>Current EC value: %PLACEHOLDER_EC_NOW% mS/m</p>
<p>Current Temperature: %PLACEHOLDER_TEMPERATURE% C</p>
<p>Sonar: %PLACEHOLDER_SONAR% cm</p>
</font> <font size="5" face="arial" color="blue">
<p>cal_EC25 from ROM: %PLACEHOLDER_CALECROM% </p>
<form action="/get">
Enter the EC value of this fluid <BR>(e.g. 1.4):
<BR><input type="text" name="input1" style="font-size:18pt;height:33px;width:55px;">
<input type="submit" value="Submit" id="Submit" value="Submit" style="height:40px; width:55px" />
<p>Calibration now = %PLACEHOLDER_EC_SET% mS/m</p>
// ==========================================================HTML web page to handle 1 input field (input1) END

//------------------------------------------ data for placeholders in index_html code START
String processor(const String& var)
return String(temperature);
else if(var == "PLACEHOLDER_EC_SET"){
return inputMessage;
else if(var == "PLACEHOLDER_EC_NOW"){
return String(EC25*cal_EC25);
else if(var == "PLACEHOLDER_SONAR"){
return String(distance_cm);
else if(var == "PLACEHOLDER_CALECROM"){
return String(cal_EC25);
return String();
//------------------------------------------ data for placeholders in index_html code END

void notFound(AsyncWebServerRequest *request) {
request->send(404, "text/plain", "Not found");
float volts, volts0, volts1, volts2, volts3 ,current, EC;
float voltslow, volts0low, volts1low, volts2low, volts3low;
float TemperatureCoef = 0.019; //this changes depending on what chemical we are measuring - seems OK for salt and Masterblend mix
float seriesR = 1000;
float getTemperature() {
DS18B20.requestTemperatures(); // send the command to get temperatures
float tempC = DS18B20.getTempCByIndex(0); // read temperature in 癈
return tempC;
//-----------------------枛枛枛-------------------------------------------------------------------------------------------SETUP START
void setup(void)
// setup pin 5 as a digital output pin to drive the EC probe pulses
pinMode (ledPin, OUTPUT);
// setup pin 18 as a digital output pin to pulse the orange LED to indicate the duration of the ADC read period
pinMode (LED_orange, OUTPUT);
pinMode(CALswitch, INPUT);

//北北北北北北北北北北北北北北北北北北北 SONAR - no include needed
pinMode(trigger_pin, OUTPUT);
pinMode(echo_pin, INPUT);
//北北北北北北北北北北北北北北北北北北北 SONAR

//------------------------------------------------------------------------------------for SSD1306 display start
if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println(F("SSD1306 allocation failed"));
display.setTextSize(3); //3 lines of text squeezed in
display.setCursor(0, 0);
// Display static text
display.setCursor(0, 33);
Serial.println("startup done");
//----------------------------------------------------------------------------------------SSD1306 display end

DS18B20.begin(); // initialize the DS18B20 sensor
WiFi.begin(ssid, password);
if (WiFi.waitForConnectResult() != WL_CONNECTED) {
Serial.println("WiFi Failed!");
Serial.print("IP Address: ");

// for curl from raspberry pi - example - my curl data is a comma delimited string at
// Define a route to serve the HTML page
server.on("/curldata.html", HTTP_GET, [](AsyncWebServerRequest *request) {

// get temperature from sensor
float temperature = getTemperature();
// Format the temperature with one decimal place
String temperatureStr = String(temperature, 1);

// put the temperature into the string curlhtml to send as a text item for curl from Pi on my network
String curlhtml = temperatureStr;
curlhtml += ",";
curlhtml += EC;
curlhtml += ",";
curlhtml += EC25;
curlhtml += ",";
curlhtml += realEC25now;
curlhtml += ",";
curlhtml += distance_cm;
Serial.print("data for curl = ");

request->send(200, "text/html", curlhtml);
// Send web page with input fields to client
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
request->send_P(200, "text/html", index_html, processor); //NB I added processor and changed " to %
// Send a GET request to <ESP_IP>/get?input1=<inputMessage> - in my example - for fluid of EC 1.4
server.on("/get", HTTP_GET, [] (AsyncWebServerRequest *request) {
String inputParam;
// GET input1 value on <ESP_IP>/get?input1=<inputMessage>
if (request->hasParam(PARAM_INPUT_1)) {
inputMessage = request->getParam(PARAM_INPUT_1)->value();
inputParam = PARAM_INPUT_1;
setEC25 = inputMessage.toFloat(); // tis is the value we keyed in on the we page - the known EC of the fluid from a wand

cal_EC25 = setEC25/EC25; // the multiplier that turns EC25 into trueEC (= EC25*cal_EC25) and then later probe the tank and trueEC25 will slowly change
preferences.putFloat("float_value", cal_EC25); //ЁЁЁЁЁЁЁЁЁЁЁЁЁЁЁЁЁ PREFERENCES CODE save cal_EC25 in ROM

else {
inputMessage = "No message sent";
inputParam = "none";

Serial.print("inputMessage = ");
Serial.print("EC25 = ");
Serial.print("new cal_EC25 = ");

request->send(200, "text/html", "<!DOCTYPE HTML><html><head><title>Confirm</title><meta name='viewport' content='width=device-width, initial-scale=1'></head><body><font size='5' face='arial' color='red'> The EC value you sent was "
+ inputMessage
+ "<br><a href=\"/\">Return to Home Page</a></font></body></html>");

Serial.println("Getting single-ended readings from AIN0..3");
Serial.println("ADC Range: +/- 6.144V (1 bit = 3mV/ADS1015, 0.1875mV/ADS1115)");
if (!ads.begin())
Serial.println("Failed to initialize ADS.");
while (1);
//-----------------------枛枛枛-------------------------------------------------------------------------------SETUP END

void loop(void) //>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>. LOOP START
// multiply the EC25 number by cal_EC25 to give real EC25 for the cal liquid as measured by a wand

preferences.begin("my_variables", false); //ЁЁЁЁЁЁЁЁЁЁЁЁ PREFERENCES CODE - get cal_EC25 from ROM (saved even if power goes off)
cal_EC25 = preferences.getFloat("float_value", 0.0); //ЁЁЁЁЁЁЁЁЁЁЁЁ PREFERENCES CODE

Serial.print("cal_EC25 from ROM = ");

int16_t adc0, adc1, adc2, adc3; //use for the high pulse
int16_t adc0low, adc1low, adc2low, adc3low; // use to check the adc inputs are zero just before the next pulse rises - use 1 megohms to drain any leakage to 0

Serial.print (" =====retrieved value of cal_EC25 - multiply EC25 by this for realEC25now");

//=============================================================================================== EC drive Pulse HIGH
digitalWrite (ledPin, HIGH); // turn on the white LED
delay(10); // measure this long after pulse rises

digitalWrite (LED_orange, HIGH); // turn on the orange LED to indicate start of adc measurements
adc0 = ads.readADC_SingleEnded(0);
adc1 = ads.readADC_SingleEnded(1);
adc2 = ads.readADC_SingleEnded(2);
adc3 = ads.readADC_SingleEnded(3);
digitalWrite (LED_orange, LOW); // turn off the green LED to indicate end of adc measurements

delay(10); // pulse length is compute time plus 300 ms make shorter and use 1000 mfd???
digitalWrite (ledPin, LOW); // turn off the LED
//================================================================================================= EC drive Pulse LOW

volts0 = ads.computeVolts(adc0);
volts1 = ads.computeVolts(adc1);
volts2 = ads.computeVolts(adc2);
volts3 = ads.computeVolts(adc3);
current = (volts3 - volts2) / seriesR; //amps for 1000 ohms series R = I=V/R
volts = volts1 - volts0; // volts across two central floating probes
EC = current * 1000/volts; // R=V/I conductivity = 1/R = I/V

// get temperature from sensor
temperature = getTemperature();
// Format the temperature with one decimal place
// String temperatureStr = String(temperature, 1);

EC25 = EC / (1+ TemperatureCoef*(temperature-25.0)); // calculate "EC25"

// Print the ESP32's IP address
Serial.print(" IP : ");

Serial.print("temperature = ");Serial.println(temperature);
Serial.print("(temperature-25.0) = "); Serial.println((temperature-25.0));
Serial.print("(1+ TemperatureCoef*(temperature-25.0)) = ");
Serial.println((1+ TemperatureCoef*(temperature-25.0)));

Serial.print("AIN0: "); Serial.print(adc0);
Serial.print(" AIN1: "); Serial.print(adc1);
Serial.print(" AIN2: "); Serial.print(adc2);
Serial.print(" AIN3: "); Serial.println(adc3);

Serial.print(volts0); Serial.print(" V ______");
Serial.print(volts1); Serial.print(" V ______ ");
Serial.print(volts2); Serial.print(" V ______ ");
Serial.print(volts3); Serial.println(" V");

Serial.print("current = ");Serial.print(current*1000);Serial.print(" mA - volts across cntral probe pair = ");Serial.println(volts);
Serial.print(" EC raw = "); Serial.print(EC); Serial.print(" EC25 = "); Serial.println(EC25);
realEC25now = EC25*cal_EC25;
Serial.print(" The calibration multiplier in use (cal_EC25 = )"); Serial.println(cal_EC25);
Serial.print(" realEC25now = "); Serial.println(realEC25now);
Serial.println(" ");
Serial.println(" ");
Serial.println(" ");
//-----------------------------------------------------------------------feed the SSD1306 OLED display START
display.setCursor(0, 0);
//Display measured data
display.setCursor(0, 22);
display.setCursor(0, 44);
//-------------------------------------------------------------------------feed the SSD1306 OLED display END

//----------------------------------------------------------------------------------------------------------------------------------------------calibration routine start

// "setEC25" is the number you key in on the webpage - it is held in RAM while the ESP32 calculates the "cal_EC25" multiplier
// that is stored in ROM - it turns the electronics measurement into a genuine EC value.

CAL_LED = digitalRead(CALswitch); // value 1 for button not pressed
Serial.print("CAL_LED = "); Serial.println(CAL_LED);

// If you hold the cal button down CAL_LED will be 0 for the next line and we enter this manual routine to make the EC value reported for
// this fluid equal to the number keyed in from the web page. If you key in 1.4 (say) the probe will report 1.4 but if you leave the probe in the fluid
// for a while it may drift a little as the temperature of the probe becomes more equal to the fluid temperature.
// so press it again to make the reading 1.4 (say) again - no need to key in the wand value again unless the power went off.
// A calibration multiplier, cal_EC25, will be created and stored in ROM
// Then the reading will follow changes in the liquid EC and report them continuously to the web.

if (CAL_LED==0) {
digitalWrite (LED_orange, HIGH);
// now in the manual re-calibration routine
// EC25 is just a number given by the electronics from the voltage and current measurements. It tracks up and down as the true EC goes up and down.
// EC25 might perhaps go from 0.5 to 1.0 when the setEC25 goes from 2 to 4 - so in this case we would multiply EC25 by (setEC25/EC25) = 2
// setEC25 is the EC of the fluid as measured by a calibrated wand or the known EC if instead we are using a standard salt solution of known EC.
// this calibration factor must be (setEC25 as we key it in)/(EC25 measured at calibration time)
// So at calibration time we key in the true fluid EC and then press the cal button and cal_EC25 is saved to ROM
// we save setEC25/EC25 in ROM as cal_EC25 - ths is then the factor we multiply future EC25 readings by.

// We saved the cal_EC25 multiplier first by keying in setEC25 (the known EC of he liquid) from the web page.
// shortly after with the probe in the same solution we repeat the calculation having waited a while for readings to settle

cal_EC25 = setEC25/EC25; // the multiplier that turns EC25 into trueEC (= EC25*cal_EC25) and then later probe the tank and trueEC25 will slowly change
preferences.putFloat("float_value", cal_EC25); //ЁЁЁЁЁЁЁЁЁЁЁЁЁЁЁЁЁ PREFERENCES CODE save cal_EC25 in ROM

Serial.println("display CAL DONE");
display.setCursor(0, 0);
display.setCursor(0, 33);
delay (4000);
digitalWrite (LED_orange, LOW);
//----------------------------------------------------------------------------------------------------------------------------------------------------calibration routine end

delay(5000); // wait

// measure the base line to check that the next pulse starts clean at zero volts
adc0low = ads.readADC_SingleEnded(0);
adc1low = ads.readADC_SingleEnded(1);
adc2low = ads.readADC_SingleEnded(2);
adc3low = ads.readADC_SingleEnded(3);

volts0low = ads.computeVolts(adc0low);
volts1low = ads.computeVolts(adc1low);
volts2low = ads.computeVolts(adc2low);
volts3low = ads.computeVolts(adc3low);

Serial.println("ADC samples taken just before drive pulse - all low?");
Serial.print(volts0low); Serial.print(" V ______");
Serial.print(volts1low); Serial.print(" V ______ ");
Serial.print(volts2low); Serial.print(" V ______ ");
Serial.print(volts3low); Serial.println(" V");

Serial.println(" ");

//北北北北北北北北北北北北北北北北北北北 SONAR
digitalWrite(trigger_pin, LOW);
digitalWrite(trigger_pin, HIGH);
digitalWrite(trigger_pin, LOW);
long duration = pulseIn(echo_pin, HIGH);
distance_cm = (duration / 2) / 29.09;
Serial.print("SONAR - cm to water surface = ");
//北北北北北北北北北北北北北北北北北北北 SONAR

delay(10); // allow the next pulse to rise at least this long after measuring the base line

//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> LOOP END

Home screen  e.g.

Confirmation screen.

I entered 1.4 into the home screen form and pressed submit.

If I view

The page just shows a string like :-

(temperature,EC,EC25,trueEC25,sonar distance to surface)

A raspberry pi on the network grabs this by curl and uses gnuplot to plot a graph of temperature and EC values as a function of the time of day.

All on a board for a small box (a bit too small!)
A 3.3 to 5 volt converter board was added under the display for the ultrasonic ranger board.
The A to D board, the bipolar capacitors, the Zener diodes and the 1 Megohm resistors are under the ESP32
The version of the probe that fits a 15mm/half inch plastic pipe.
Brushes and thermometer soldered in before covering in hot glue. The cable goes up the pipe.