Introduction: Brine Solution Automation (ESP32 + FreeRTOS + Web Dashboard+Multi Threading)

About: I am a student of mechanical engineering. The name of my institute is Chittagong University of Engineering and Technology (CUET).

This Instructable on building an automated brine solution dispensing system using an ESP32 microcontroller! This project is designed for applications that require precise control over dispensing a brine solution, such as in food processing, water treatment, or industrial automation. The system supports two primary dispensing modes: time-based (e.g., operating the pump for a specified duration) and volume-based (e.g., dispensing a predetermined volume measured by a flow sensor). It also includes a conveyor belt control triggered by a proximity sensor, making it ideal for automated workflows like filling containers on a moving line.

📦 Features

  1. 🧠 Dual-Core FreeRTOS:
  2. Core-0 handles the web server, LCD, proximity sensor & conveyor
  3. Core-1 handles pump logic, physical button, and flow aggregation
  4. 🌐 WiFi Access Point + Web Dashboard
  5. – Configure mode, time, volume, conveyor time
  6. – Real-time flow reading and current settings
  7. 🧪 Volume-Based Dosing using flow sensor (pulses-per-liter calibration)
  8. 🚀 Proximity-triggered Conveyor System
  9. 💾 Auto Save Settings (Preferences) – survives reboot
  10. 📟 I2C LCD for live system status
  11. 🔧 Custom PCB + 3D Printed Case

📐 System Architecture

Core-0 (TaskCore0):

  1. WiFi Access Point (ESP32_Auto)
  2. Web Server (settings + dashboard)
  3. LCD status update
  4. Proximity sensor → Conveyor control

Core-1 (PumpTask + ButtonTask):

  1. Pump start based on the button or the dashboard
  2. Time mode / Volume mode logic
  3. Flow pulse aggregation at 1-second intervals

Interrupt:

  1. Flow sensor ISR (counts pulses)

I designed the circuit, fabricated the PCB, and created a 3D-printed casing for the PCB to make it compact and durable. It takes about 4-6 hours to assemble if you have basic soldering and 3D printing skills.

This system is "brine solution automation" because it's tailored for handling saline (brine) liquids, but it can be adapted for other fluids. Safety note: Ensure all components are rated for your liquid's corrosiveness—brine can be harsh on metals.


Supplies

Hardware Components:

  1. ESP32-WROOM-32 development board----1Pcs
  2. Relay ----2Pcs
  3. D882 ----2 Pcs
  4. Push button (momentary switch) ----1Pcs
  5. Flow sensor (YF-DN50)(G2 / 2″) ----1Pcs
  6. Proximity sensor ----1Pcs
  7. 16x2 LCD with I2C backpack ----1Pcs
  8. Power supply (24V ) ----1Pcs
  9. LM2596 Buck Converter (for ESP32) ----1Pcs
  10. Screw Terminal (5mm)----6Pcs
  11. Capacitors
  12. Resistors
  13. DN50 Stainless Steel Pneumatic Angle Seat Valve ----1Pcs
  14. DC 24V 3 Way 2 Position Pneumatic Electric Solenoid Valve ----1Pcs
  15. Custom PCB

Tools and Materials:

  1. Soldering iron and solder.
  2. 3D printer (for casing) or enclosure box.
  3. Multimeter for testing.
  4. USB cable for programming the ESP32.

Software:

  1. Arduino IDE with ESP32 board support installed.
  2. Libraries: ArduinoJson, WiFi, WebServer, Preferences, LiquidCrystal_I2C, FreeRTOS (built-in for ESP32).

Step 1: Understanding the System Overview

The system works like this:

  1. Core 0 (ESP32) handles WiFi access point, web server for configuration, LCD updates, proximity sensor for conveyor control.
  2. Core 1 manages the start button, flow sensor pulse counting (via ISR), and pump control logic.
  3. Modes:
  4. Mode 1 (Time): Pump runs for a set duration (e.g., 5 seconds).
  5. Mode 2 (Volume): Pump runs until a target liters is reached, measured by the flow sensor.
  6. Mode 3 (Disabled): No dispensing, but conveyor still works.
  7. Conveyor activates for a set time when proximity detects an object (e.g., a container).
  8. Settings are configurable via a web page (access at 192.168.10.1) or defaults in code.
  9. Flow is aggregated every second to avoid ISR overhead.

The code uses semaphores for thread-safe access to shared variables like total liters dispensed.

Step 2: Circuit Design and Wiring

Pins Configuration:

Relay ---- D13

Button ---- D19

SDA ---- D21 (Display)

SCL ----- D22 (Display)

Sensors

D34, D35, D32, D33, D27

Step 3: 3D Modeling the PCB Casing

I designed the casing in SolidWorks. The model is a box enclosure with:

  1. Cutouts for LCD screen, button, and sensor wires.
  2. Mounting points for the PCB (standoffs).
  3. Lid with screws for easy access.

Print with PLA+ filament.

Step 4: Code Upload and Configuration

  1. Install Arduino IDE and add the ESP32 board manager (URL: https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json).
  2. Install libraries via Library Manager: ArduinoJson, LiquidCrystal_I2C.
  3. Copy the code below into a new sketch.
  4. Update pulsesPerLiter based on your flow sensor calibration (test by measuring a known volume and counting pulses).
  5. Upload to ESP32 (select the correct board and port).
  6. Open Serial Monitor (115200 baud) to see setup logs.
/*
ESP32 FreeRTOS multi-core sketch
Core 0: WiFi + WebServer + LCD + Proximity + Conveyor
Core 1: Button + Flow aggregation + Pump control
*/

#include <ArduinoJson.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <LiquidCrystal_I2C.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"

// ========== HARDWARE PINS ==========
#define RELAY_PIN 13 // Pump / valve relay (activated by button/pump task)
#define BUTTON_PIN 19 // Physical start button (INPUT_PULLUP)
#define FLOW_SENSOR_PIN 35 // Flow sensor (ISR increments pulse count)
#define CONVEYOR_PIN 32 // Conveyor relay (ON/OFF via proximity on core0)
#define PROX_PIN 33 // Proximity sensor (INPUT_PULLUP; LOW = detected)
#define I2C_ADDR 0x27 // LCD I2C address

// ========== GLOBALS & PERFS ==========
LiquidCrystal_I2C lcd(I2C_ADDR, 16, 2);
Preferences prefs;
WebServer server(80);

const char* ssid = "ESP32_Auto";
const char* password = "12345678";
IPAddress local_IP(192, 168, 10, 1);
IPAddress gateway(192, 168, 10, 1);
IPAddress subnet(255, 255, 255, 0);

#define PREF_NAMESPACE "esp32auto"
#define KEY_MODE "mode"
#define KEY_TIME "time"
#define KEY_LITER "liter"
#define KEY_PPL "ppl"
#define KEY_CONV "conv"

// defaults
volatile int modeSetting = 1;
volatile unsigned long mode1Seconds = 5;
volatile float mode2Liters = 10.0;
volatile float pulsesPerLiter = 12; // our value 71.5125
volatile unsigned long conveyorSeconds = 3;

// flow variables
volatile unsigned long pulseCount = 0; // incremented in ISR
float totalLiters = 0.0; // aggregated once a second (protected by mutex)
unsigned long lastFlowCalc = 0;

// mutex to protect totalLiters
SemaphoreHandle_t xLitMutex = NULL;

// pump/sequence flags (Core1)
volatile bool pumpRunning = false;

// conveyor state (Core0)
volatile bool conveyorRunning = false;
unsigned long conveyorStartMillis = 0;
unsigned long conveyorDurationMs = 0;

// button debounce state (Core1)
const unsigned long BUTTON_DEBOUNCE_MS = 50;
unsigned long lastButtonDebounceMillis = 0;
int lastButtonStable = HIGH;

// proximity debounce (Core0)
const unsigned long PROX_DEBOUNCE_MS = 50;
unsigned long lastProxDebounceMillis = 0;
int lastProxStable = HIGH;

// HTML page (same UI as earlier)
const char* htmlPage = R"rawliteral(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Brine Automation Dashboard</title>
<style>
body { font-family: Arial, sans-serif; background: #f4f7f9; margin: 0; padding: 0; }
.container { max-width: 480px; width: 90%; margin: 40px auto; background: #fff; padding: 20px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
h2 { text-align: center; color: #0077cc; margin-bottom: 20px; }
label { font-weight: bold; display: block; margin-top: 10px; }
input, select, button { width: 100%; padding: 10px; margin-top: 6px; font-size: 16px; border-radius: 6px; border: 1px solid #ccc; box-sizing: border-box; }
button { background: #0077cc; color: white; border: none; margin-top: 15px; cursor: pointer; transition: 0.3s; }
button:hover { background: #005fa3; }
#msg { text-align: center; margin-top: 10px; font-weight: bold; color: green; }
</style>
</head>
<body>
<div class="container">
<h2>ESP32 Automation</h2>
<p>Current Mode: <span id="curMode">Loading...</span></p>

<label>Select Mode:</label>
<select id="mode">
<option value="1">Mode 1 - Time</option>
<option value="2">Mode 2 - Volume</option>
<option value="3">Mode 3 - Disabled</option>
</select>

<div id="mode1Box">
<label>Seconds:</label>
<input id="time" type="number" min="1" value="5">
</div>

<div id="mode2Box" style="display:none;">
<label>Liters:</label>
<input id="lit" type="number" min="1" value="10">
</div>

<label>Conveyor Time (sec):</label>
<input id="conv" type="number" min="1" value="3">

<button onclick="save()">Save</button>
<p id="msg"></p>
</div>

<script>
async function loadStatus(){
let r = await fetch('/status');
if(r.ok){
let d = await r.json();
document.getElementById('curMode').innerText = d.mode;
document.getElementById('mode').value = d.mode;
document.getElementById('time').value = d.time;
document.getElementById('lit').value = d.liter;
document.getElementById('conv').value = d.conv;
switchModeUI();
}
}

async function save(){
let data = {
mode: parseInt(document.getElementById('mode').value),
time: parseInt(document.getElementById('time').value),
liter: parseFloat(document.getElementById('lit').value),
conv: parseInt(document.getElementById('conv').value)
};
let r = await fetch('/save', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(data)
});
if(r.ok){
document.getElementById('msg').innerText = '✅ Settings Saved!';
loadStatus();
} else {
document.getElementById('msg').innerText = '❌ Save failed';
}
}

document.getElementById('mode').addEventListener('change', switchModeUI);
function switchModeUI(){
let m = document.getElementById('mode').value;
document.getElementById('mode1Box').style.display = (m=='1')?'block':'none';
document.getElementById('mode2Box').style.display = (m=='2')?'block':'none';
}

loadStatus();
</script>
</body>
</html>
)rawliteral";

// ========== PREFERENCES ==========

void saveSettings() {
prefs.begin(PREF_NAMESPACE, false);
prefs.putInt(KEY_MODE, (int)modeSetting);
prefs.putULong(KEY_TIME, mode1Seconds);
prefs.putFloat(KEY_LITER, mode2Liters);
prefs.putFloat(KEY_PPL, pulsesPerLiter);
prefs.putULong(KEY_CONV, conveyorSeconds);
prefs.end();
Serial.printf("Saved settings: mode=%d time=%lu liter=%.2f conv=%lu ppl=%.2f\n",
(int)modeSetting, mode1Seconds, mode2Liters, conveyorSeconds, pulsesPerLiter);
}

void loadSettings() {
prefs.begin(PREF_NAMESPACE, true);
modeSetting = prefs.getInt(KEY_MODE, 1);
mode1Seconds = prefs.getULong(KEY_TIME, 5);
mode2Liters = prefs.getFloat(KEY_LITER, 10.0);
pulsesPerLiter = prefs.getFloat(KEY_PPL, 71.5125);
conveyorSeconds = prefs.getULong(KEY_CONV, 3);
prefs.end();
Serial.printf("Loaded settings: mode=%d time=%lu liter=%.2f conv=%lu ppl=%.2f\n",
(int)modeSetting, mode1Seconds, mode2Liters, conveyorSeconds, pulsesPerLiter);
}

// ========== FLOW ISR & AGGREGATION ==========
void IRAM_ATTR flowISR() {
pulseCount++;
}

// Called periodically on Core1 to aggregate pulses into liters into totalLiters
void aggregateFlow() {
unsigned long now = millis();
if (now - lastFlowCalc >= 1000UL) { // every 1 second
noInterrupts();
unsigned long pulses = pulseCount;
pulseCount = 0;
interrupts();

float litersThisSec = (float)pulses / pulsesPerLiter;

// protect totalLiters
if (xLitMutex != NULL && xSemaphoreTake(xLitMutex, (TickType_t)10) == pdTRUE) {
totalLiters += litersThisSec;
xSemaphoreGive(xLitMutex);
} else {
// fallback (shouldn't happen often)
totalLiters += litersThisSec;
}

Serial.printf("Flow agg: pulses=%lu +%.4f L total=%.4f\n", pulses, litersThisSec, totalLiters);
lastFlowCalc = now;
}
}

// ========== WEB HANDLERS ==========
void handleRoot() {
server.send(200, "text/html", htmlPage);
}

void handleStatus() {
float safeLit = 0.0;
if (xLitMutex != NULL && xSemaphoreTake(xLitMutex, (TickType_t)10) == pdTRUE) {
safeLit = totalLiters;
xSemaphoreGive(xLitMutex);
} else {
safeLit = totalLiters;
}
String json = "{\"mode\":" + String((int)modeSetting)
+ ",\"time\":" + String(mode1Seconds)
+ ",\"liter\":" + String(mode2Liters)
+ ",\"conv\":" + String(conveyorSeconds)
+ ",\"flow\":" + String(safeLit)
+ "}";
server.send(200, "application/json", json);
}

void handleSave() {
String body = server.arg("plain");
DynamicJsonDocument doc(512);
DeserializationError err = deserializeJson(doc, body);
if (err) {
server.send(400, "application/json", "{\"ok\":false, \"error\":\"bad json\"}");
return;
}
if (doc.containsKey("mode")) modeSetting = doc["mode"].as<int>();
if (doc.containsKey("time")) mode1Seconds = doc["time"].as<unsigned long>();
if (doc.containsKey("liter")) mode2Liters = doc["liter"].as<float>();
if (doc.containsKey("conv")) conveyorSeconds = doc["conv"].as<unsigned long>();
saveSettings();
server.send(200, "application/json", "{\"ok\":true}");
}

// ========== TASKS ==========

// Core0: Web + LCD + Proximity & Conveyor
void TaskCore0(void* pv) {
(void)pv;
Serial.println("TaskCore0 starting on core " + String(xPortGetCoreID()));

// setup AP and server
WiFi.softAP(ssid, password);
WiFi.softAPConfig(local_IP, gateway, subnet);
Serial.print("AP IP: ");
Serial.println(WiFi.softAPIP());

#if defined(WEB_SERVER_ENABLE_CORS)
server.enableCORS(true);
#endif
server.on("/", handleRoot);
server.on("/status", handleStatus);
server.on("/save", HTTP_POST, handleSave);
server.begin();
Serial.println("Web server started (Core0).");

unsigned long lastLCD = 0;
const unsigned long LCD_INTERVAL = 700;

for (;;) {
// handle web clients frequently
server.handleClient();

// Proximity debounce & control conveyor (INPUT_PULLUP; LOW = detected)
int prox = digitalRead(PROX_PIN);
if (prox != lastProxStable) {
lastProxDebounceMillis = millis();
lastProxStable = prox;
} else {
if ((millis() - lastProxDebounceMillis) > PROX_DEBOUNCE_MS) {
// stable
if (prox == LOW) { // detection
if (!conveyorRunning) {
conveyorRunning = true;
conveyorStartMillis = millis();
conveyorDurationMs = conveyorSeconds * 1000UL;
digitalWrite(CONVEYOR_PIN, HIGH);
Serial.printf("Proximity detected -> Conveyor ON for %lu ms\n", conveyorDurationMs);
}
}
// If prox is HIGH we do nothing special: conveyor will auto-stop after duration
}
}

// conveyor timing (non-blocking)
if (conveyorRunning) {
if (millis() - conveyorStartMillis >= conveyorDurationMs) {
conveyorRunning = false;
digitalWrite(CONVEYOR_PIN, LOW);
Serial.println("Conveyor auto-stopped (Core0).");
}
}

// LCD update: read totalLiters safely
if (millis() - lastLCD >= LCD_INTERVAL) {
float safeLit = 0.0;
if (xLitMutex != NULL && xSemaphoreTake(xLitMutex, (TickType_t)10) == pdTRUE) {
safeLit = totalLiters;
xSemaphoreGive(xLitMutex);
} else {
safeLit = totalLiters;
}

// Update LCD content
lcd.clear();
switch (modeSetting) {
case 1:
lcd.print("Mode1: Time ");
lcd.setCursor(0, 1);
lcd.print("Time:");
lcd.print(mode1Seconds);
lcd.print("s ");
break;
case 2:
lcd.print("Mode2: Volume");
lcd.setCursor(0, 1);
lcd.print(safeLit, 2);
lcd.print("/");
lcd.print(mode2Liters);
lcd.print("L");
break;
default:
lcd.print("Mode3 Disabled");
lcd.setCursor(0, 1);
lcd.print("Conv:");
lcd.print(conveyorSeconds);
break;
}

lastLCD = millis();
}

vTaskDelay(pdMS_TO_TICKS(40)); // yield
}
}

// Core1: Flow aggregation and Pump control + Button handling
TaskHandle_t pumpTaskHandle = NULL;

void PumpTask(void* pv) {
(void)pv;
Serial.println("PumpTask starting on core " + String(xPortGetCoreID()));

for (;;) {
// Wait to be notified by the Button task when a start is requested
// ulTaskNotifyTake blocks until a notification arrives (or timeout)
uint32_t notified = ulTaskNotifyTake(pdTRUE, portMAX_DELAY); // clear on receive
if (notified > 0) {
// If mode disabled ignore
if (modeSetting == 3) {
Serial.println("PumpTask: mode disabled -> ignoring start");
continue;
}

if (pumpRunning) {
Serial.println("PumpTask: pump already running -> ignoring");
continue;
}

// Start pump according to mode
if (modeSetting == 1) {
// Time based
pumpRunning = true;
digitalWrite(RELAY_PIN, HIGH);
Serial.printf("PumpTask: time-based run for %lu s\n", mode1Seconds);
unsigned long start = millis();
while (millis() - start < mode1Seconds * 1000UL) {
aggregateFlow(); // keep aggregating flow during pump
vTaskDelay(pdMS_TO_TICKS(200));
}
digitalWrite(RELAY_PIN, LOW);
pumpRunning = false;
Serial.println("PumpTask: time run finished.");
} else if (modeSetting == 2) {
// Volume based
pumpRunning = true;
// Reset flow counters safely
if (xLitMutex != NULL && xSemaphoreTake(xLitMutex, (TickType_t)10) == pdTRUE) {
totalLiters = 0.0;
xSemaphoreGive(xLitMutex);
} else {
totalLiters = 0.0;
}
noInterrupts();
pulseCount = 0;
interrupts();

digitalWrite(RELAY_PIN, HIGH);
unsigned long startMillis = millis();
Serial.printf("PumpTask: volume-based target %.2f L\n", mode2Liters);
while (true) {
aggregateFlow();
float safeLit = 0.0;
if (xLitMutex != NULL && xSemaphoreTake(xLitMutex, (TickType_t)10) == pdTRUE) {
safeLit = totalLiters;
xSemaphoreGive(xLitMutex);
} else {
safeLit = totalLiters;
}

if (safeLit >= mode2Liters) {
Serial.printf("PumpTask: target reached %.3f L\n", safeLit);
break;
}
// safety timeout (10 min)
if (millis() - startMillis > 10UL * 60UL * 1000UL) {
Serial.println("PumpTask: timeout waiting for volume -> abort");
break;
}
vTaskDelay(pdMS_TO_TICKS(250));
}
digitalWrite(RELAY_PIN, LOW);
pumpRunning = false;
Serial.println("PumpTask: volume run finished.");
} // modes
}
}
}

void ButtonTask(void* pv) {
(void)pv;
Serial.println("ButtonTask starting on core " + String(xPortGetCoreID()));
int lastStable = HIGH;
lastButtonStable = HIGH;
for (;;) {
int b = digitalRead(BUTTON_PIN);
if (b != lastStable) {
lastButtonDebounceMillis = millis();
lastStable = b;
} else {
if ((millis() - lastButtonDebounceMillis) > BUTTON_DEBOUNCE_MS) {
// falling edge detection
static int prevState = HIGH;
if (b == LOW && prevState == HIGH) {
Serial.println("ButtonTask: button pressed -> notify pump");
// Notify (unblock) PumpTask
if (pumpTaskHandle != NULL) {
xTaskNotifyGive(pumpTaskHandle);
}
}
prevState = b;
}
}

// also aggregate flow here regularly
aggregateFlow();

vTaskDelay(pdMS_TO_TICKS(40));
}
}

// ========== SETUP & LOOP ==========
void setup() {
Serial.begin(115200);
delay(50);

// pins
pinMode(RELAY_PIN, OUTPUT);
pinMode(CONVEYOR_PIN, OUTPUT);
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(FLOW_SENSOR_PIN, INPUT_PULLUP);
pinMode(PROX_PIN, INPUT_PULLUP);

digitalWrite(RELAY_PIN, LOW);
digitalWrite(CONVEYOR_PIN, LOW);

attachInterrupt(digitalPinToInterrupt(FLOW_SENSOR_PIN), flowISR, RISING);

// lcd
lcd.init();
lcd.backlight();

// mutex
xLitMutex = xSemaphoreCreateMutex();

// load settings
loadSettings();

// create tasks pinned to cores
BaseType_t r0 = xTaskCreatePinnedToCore(TaskCore0, "TaskCore0", 8192, NULL, 1, NULL, 0); // Core 0
BaseType_t rPump = xTaskCreatePinnedToCore(PumpTask, "PumpTask", 8192, NULL, 2, &pumpTaskHandle, 1); // Core 1
BaseType_t rButton = xTaskCreatePinnedToCore(ButtonTask, "ButtonTask", 4096, NULL, 2, NULL, 1); // Core 1

if (r0 != pdPASS || rPump != pdPASS || rButton != pdPASS) {
Serial.println("Task creation failed!");
} else {
Serial.println("Tasks created successfully.");
}
}

void loop() {
// Empty - tasks do the work
vTaskDelay(pdMS_TO_TICKS(1000));
}


After upload:

  1. Connect to WiFi "ESP32_Auto" with password "12345678".
  2. Browse to http://192.168.10.1.
  3. Set mode, time/volume, conveyor time, and save.
  4. Test: Place the object in proximity—the conveyor should start. Press the button—the pump should run per the mode.

Step 5: Calibration and Testing

  1. Flow Sensor Calibration: Run known volume (e.g., 10L) through sensor, count pulses via Serial, set pulsesPerLiter = totalPulses / 1.0.
  2. Test Modes: Use Mode 1 for quick tests, Mode 2 for accuracy.
  3. Safety: Add fuses for the pump/conveyor. Monitor for leaks in brine lines.
  4. Debug: Watch Serial for logs like "PumpTask: target reached".