Community project

iot enabled medical device

ESP32
Photo of iot enabled medical device

TheDum -E

Published October 19, 2026

This guide builds a water-only bench research prototype for studying peristaltic pump control and flow monitoring. The ESP32 microcontroller orchestrates a TB6612FNG motor driver to regulate pump speed, monitors flow via infrared drop detection, and reads pressure data to detect occlusions. Assembly includes power distribution, motor control wiring, a physical emergency stop button, drop-rate sensing, pressure monitoring, an OLED display, and audible/visual alarms.

Following this guide, you'll receive a complete wiring diagram, parts list, and annotated firmware with serial command interface for flow calibration and remote operation. The prototype includes safety features like watchdog timers, occlusion detection, and a latching fault state. This is a research tool only—never connect to a person or use for drug delivery.

Wiring diagram

Wiring diagram for iot enabled medical device

Gather all the parts

QtyComponent
1

TB6612FNG dual H-bridge motor-driver breakout

1 A/channel breakout

A dual H-bridge board that lets the ESP32 control the pump motor without sending motor current through the ESP32.

1

5 V peristaltic micro-pump

3–5 V, stall current must be below 1 A

A small tubing pump that moves water while keeping the liquid inside the silicone tube and away from the motor.

1

TCRT5000 IR Line Tracking Sensor Module

LM393 reflective IR module; operate at 3.3 V

Single-channel infrared reflective sensor module built around the Vishay TCRT5000 IR emitter/phototransistor pair. An onboard LM393 comparator with a sensitivity trim-pot drives a thresholded digital output (DO) for line-detection or obstacle-avoidance, while a separate analog pin (AO) exposes the raw reflected-IR reading. Runs on 3.3V-5V, senses reflective surfaces roughly 1-25mm away, and is the standard low-cost sensor on line-following and obstacle-avoiding robot kits. Sold as a generic 4-pin (VCC/GND/DO/AO) breakout under many brand names (HiLetgo, ACEIRMC, OSOYOO, etc.) rather than by a single official manufacturer.

1

Push Button

red momentary, normally open

Momentary push button switch

1

5 V active buzzer

5 V active

A self-oscillating buzzer that sounds when its transistor switch is turned on during a fault.

1

LED

red, 5 mm

Standard 3mm/5mm through-hole LED. A current-limiting series resistor is added automatically.

1

Resistor

10 kΩ

Through-hole resistor (current-limiting in series with an LED)

1

Resistor

220 Ω

Through-hole resistor (current-limiting in series with an LED)

1

USB-C 5V Adapter

5 V, 2 A minimum

USB-C wall adapter delivering regulated 5 V to the board's USB or VBUS rail. Default wired power source for desktop / stationary projects.

1

NPN Transistor (2N2222 / BC547)

2N2222 or BC547; verify lead order

General-purpose NPN bipolar junction transistor (BJT) in TO-92 package. Used as a low-side switch to drive loads (e.g. buzzers) whose current exceeds ESP32 GPIO source capability (~12 mA). A ~1 kΩ base resistor is placed between the ESP32 GPIO and the transistor base. The collector connects to the load negative terminal (buzzer –), and the emitter connects to GND. When the GPIO goes HIGH (3.3 V), the transistor saturates and switches the load on. Compatible with both 2N2222/2N2222A and BC547/BC547B variants. Add a flyback diode (e.g. 1N4148) across inductive load terminals.

1

Resistor

1 kΩ

Through-hole resistor (current-limiting in series with an LED)

1

C1 TB6612 logic decoupling capacitor

0.1 µF ceramic

A small capacitor placed beside the driver to keep its 3.3 V logic supply stable.

1

C2 motor bulk capacitor

100 µF, 10 V or higher

A large capacitor placed beside the driver motor supply to absorb short pump-current surges.

1

1N5819 Schottky diode

1N5819, 1 A 40 V Schottky

A striped-end diode wired across the buzzer to absorb the voltage spike when the buzzer switches off.

1

HX710B 0–40 kPa digital air-pressure sensor module

0–40 kPa, 24-bit digital

A hose-connected digital module that measures pressure in the pump tubing.

1

10 kΩ linear potentiometer

10 kΩ linear

A three-leg adjustment knob that tells the ESP32 the maximum pump speed without carrying pump power.

1

SSD1306 OLED

0.96 inch 128x64 OLED display with I2C interface

1

INA219 I2C high-side current/voltage sensor module

A small module that measures the pump driver’s 5 V supply voltage and the current flowing to it.

Assemble it in 11 steps

1. Keep this as a water-only bench rig

Place the ESP32, driver board, sensor boards, screen, and tubing on a dry breadboard area. Put the pump beside the breadboard and route water only into a measuring cup; never connect this prototype to a person or medication.

  • This is a supervised water-test prototype, not a certified infusion device.

2. Make the shared power rails

Use the 5 V, 2 A supply for a 5 V rail and its ground for a ground rail. Connect ESP32 VIN/5V to 5 V (power) and ESP32 GND to ground (ground). The ESP32 3V3 pin supplies the low-power logic rail; every listed ground joins the same ground rail.

  • Disconnect 5 V before moving wires; a short can damage the ESP32 or heat wires.

3. Put the pump power monitor in line

Connect INA219 VIN+ to the 5 V rail (incoming pump power). Connect INA219 VIN− to TB6612 VM (measured pump power leaving the monitor). Connect the 100 µF capacitor positive lead to that same VM/VIN− junction (motor smoothing), and its negative striped lead to GND (ground).

  • Do not connect VIN− directly back to the 5 V rail; that bypasses current measurement. Reversed capacitor polarity can damage the capacitor.

4. Wire the motor driver and pump

Connect TB6612 VCC to ESP32 3V3 (logic power), and GND to common GND (ground). Connect AIN1 to GPIO25 (direction), AIN2 to GPIO26 (direction), and PWMA to GPIO27 (speed signal). Connect BIN1, BIN2, and PWMB to GND (unused channel). Connect A01 to pump MOTOR+ (motor power) and A02 to pump MOTOR− (motor power). Put C1 between VCC and GND (noise filtering).

  • Do not bypass the INA219 by wiring TB6612 VM directly to the 5 V rail.

5. Add the physical STOP override

Connect one normally-open button leg to GND (ground). Connect its other leg, SIGNAL, to GPIO33 (stop signal) and TB6612 STBY (motor-enable signal). Connect the 10 kΩ resistor between that SIGNAL junction and 3V3 (pull-up power). Pressing the button pulls the junction low and disables the motor driver.

  • This is helpful hardware shutdown, but not a safety-certified emergency-stop system.

6. Wire and aim the drop sensor

Connect the LM393/TCRT5000 VCC to ESP32 3V3 (power), GND to common GND (ground), and DO to GPIO32 (drop signal). Leave AO disconnected. Mount the black sensor face a few millimetres from the same drop path each time, then adjust its screw until its indicator changes for each water drop.

  • Do not power this sensor from 5 V while DO is connected to GPIO32; 5 V can damage the ESP32.

7. Wire the pressure module

With its pressure hose open to room air, connect HX710B VCC to ESP32 3V3 (power), GND to common GND (ground), DOUT to GPIO13 (data), and SCK to GPIO14 (clock). Secure its pressure port to the downstream tubing.

  • Do not use this uncalibrated module as a medical or safety-critical pressure measurement.

8. Wire the OLED screen and monitor signals

Connect OLED VCC to ESP32 3V3 (power), GND to GND (ground), SDA to GPIO21 (shared data), and SCL to GPIO22 (shared clock). Connect INA219 VCC to 3V3 (power), GND to GND (ground), SDA to GPIO21 (shared data), and SCL to GPIO22 (shared clock). These two modules intentionally share the SDA and SCL wires.

  • Do not power either I²C module from 5 V in this build; their SDA/SCL signal wires must stay at the ESP32-safe 3.3 V level.

9. Wire the alarm light and buzzer

Connect GPIO4 through the 220 Ω resistor to the LED long leg (alarm signal), then connect the short leg to GND (ground). Connect GPIO23 through the 1 kΩ resistor to the transistor base (buzzer signal), emitter to GND (ground), collector to buzzer negative (switched negative), and buzzer positive to 5 V (power). Put the diode across the buzzer: striped end to 5 V and unstriped end to the collector.

  • A reversed LED will not light; a transistor with its leads swapped can fail or heat up. The stripe on the 1N5819 must face the buzzer’s 5 V side.

10. Add the pump-speed knob

Place the 10 kΩ potentiometer across three separate breadboard rows. Connect one outer pin to ESP32 3V3 (power), the other outer pin to common GND (ground), and the middle WIPER pin to GPIO34 (speed signal). Turning the knob changes the speed limit sent to the driver; it does not carry pump current.

  • Never put this small potentiometer in series with the pump or 5 V motor wire; it can overheat.

11. Check the wireless app link and safety stops

After flashing, connect your phone or computer to Wi-Fi network WaterBench-ESP32 using password WaterBench2026. Open your app and have it request GET http://192.168.4.1/api/status every one or two seconds; the OLED changes from APP -- to APP OK after an exchange. Test the physical STOP button before pumping, then run a low-flow water test and confirm exactly three detected drops stop the pump and sound the buzzer.

  • Do not run a dose if the reflective sensor misses drops, the pressure module is not detected, or the OLED says the INA219 monitor is missing. The app Stop button supplements but never replaces the physical STOP button.

Review all connections

1. Connections between "power_supply" and "ESP32"

Functionpower_supplyESP32
power+5V5V
groundGNDGND

2. Connections between "driver" and "ESP32"

FunctiondriverESP32
powerVCC3V3
groundGNDGND
digitalAIN1GPIO 25
digitalAIN2GPIO 26
pwmPWMAGPIO 27
digitalSTBY → Push Button SIGNALEXT
groundBIN1GND
groundBIN2GND
groundPWMBGND
powerA01 → 5 V peristaltic micro-pump MOTOR+EXT
powerA02 → 5 V peristaltic micro-pump MOTOR-EXT

3. Connections between "stop_button" and "ESP32"

Functionstop_buttonESP32
groundGNDGND
digitalSIGNALGPIO 33

4. Connections between "buzzer" and "ESP32"

FunctionbuzzerESP32
powerVCC5V
groundGND → NPN Transistor (2N2222 / BC547) COLLECTOREXT

5. Connections between "alarm_led" and "ESP32"

Functionalarm_ledESP32
groundGNDGND

6. Connections between "r2_stop_pullup" and "ESP32"

Functionr2_stop_pullupESP32
powerP13V3
digitalP2 → Push Button SIGNALEXT

7. Connections between "r3_led" and "ESP32"

Functionr3_ledESP32
digitalP2 → LED ANODEEXT
digitalP1GPIO 4

8. Connections between "q1_buzzer" and "ESP32"

Functionq1_buzzerESP32
groundEMITTERGND

9. Connections between "r6_buzzer_base" and "ESP32"

Functionr6_buzzer_baseESP32
digitalP1GPIO 23
digitalP2 → NPN Transistor (2N2222 / BC547) BASEEXT

10. Connections between "c1_logic" and "ESP32"

Functionc1_logicESP32
powerPOSITIVE3V3
groundNEGATIVEGND

11. Connections between "c2_motor" and "ESP32"

Functionc2_motorESP32
groundNEGATIVEGND
powerPOSITIVE → TB6612FNG dual H-bridge motor-driver breakout VMEXT

12. Connections between "d1_buzzer" and "ESP32"

Functiond1_buzzerESP32
powerAnode → NPN Transistor (2N2222 / BC547) COLLECTOREXT
powerCathode5V

13. Connections between "ir_drop_sensor" and "ESP32"

Functionir_drop_sensorESP32
powerVCC3V3
groundGNDGND
digitalDOGPIO 32

14. Connections between "pressure_sensor_hx710b" and "ESP32"

Functionpressure_sensor_hx710bESP32
powerVCC3V3
groundGNDGND
digitalDOUTGPIO 13
digitalSCKGPIO 14

15. Connections between "speed_pot" and "ESP32"

Functionspeed_potESP32
powerVCC3V3
adcWIPERGPIO 34
groundGNDGND

16. Connections between "oled_display" and "ESP32"

Functionoled_displayESP32
powerVCC3V3
groundGNDGND
i2cSDAGPIO 21
i2cSCLGPIO 22

17. Connections between "pump_power_monitor" and "ESP32"

Functionpump_power_monitorESP32
powerVCC3V3
groundGNDGND
i2cSDAGPIO 21
i2cSCLGPIO 22
powerVIN+5V
powerVIN- → TB6612FNG dual H-bridge motor-driver breakout VMEXT

Deploy the firmware

/*
  SMART MICRO-PUMP — WATER-ONLY BENCH RESEARCH PROTOTYPE (wiring verified)
  Not a medical device. Never connect to a person or use for IV delivery.
  Commands on Serial (newline terminated):
    SET_FLOW 2.0 | START | PAUSE | STOP | ACK_ALARM | RESUME
    CAL 140 | CAL_RESULT 10.0 | CAL_STOP | STATUS
*/
#include <Arduino.h>
#include <Preferences.h>
#include <Wire.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_INA219.h>
// The watchdog is an ESP32 hardware feature. The browser simulator has no task watchdog.
#if defined(ESP32)
#include <esp_task_wdt.h>
#endif


struct CalibrationPoint { uint8_t pwm; float dropsPerSecond; float mlPerMin; };

enum PumpState { IDLE, CALIBRATING, RUNNING, PAUSED, COMPLETE, OCCLUSION_ALARM, FAULT };

enum FaultReason { NO_FAULT, STOP_PRESSED, OCCLUSION, NO_FLOW, PRESSURE_SENSOR_FAULT, IR_SENSOR_FAULT, NETWORK_TIMEOUT };


// Forward declarations
void IRAM_ATTR onDrop();
void writePumpPwm(uint8_t duty);
void pumpOff();
void alarmOutputs(bool active);
void latchFault(FaultReason why);
bool physicalStopPressed();
float readPressureKpa(bool &valid);
bool conditionsSafe();
void beginRun();
void pausePump();
void controlAndSafety();
void handleCommand(String command, bool remote);

constexpr int AIN1_PIN = 25;
constexpr int AIN2_PIN = 26;
constexpr int PUMP_PWM_PIN = 27;
constexpr int DROP_PIN = 32;
constexpr int STOP_PIN = 33;
constexpr int HX710B_DOUT_PIN = 13;
constexpr int HX710B_SCK_PIN = 14;
constexpr int ALARM_LED_PIN = 4;
constexpr int BUZZER_PIN = 23;
constexpr int SPEED_POT_PIN = 34;
constexpr int I2C_SDA_PIN = 21;
constexpr int I2C_SCL_PIN = 22;
constexpr uint8_t OLED_I2C_ADDRESS = 0x3C;

// Password-protected local Wi-Fi access point for the companion app.
// Change both values before sharing the device; the password must be at least 8 characters.
constexpr char DEFAULT_WIFI_AP_SSID[] = "WaterBench-ESP32";
constexpr char WIFI_AP_PASSWORD[] = "WaterBench2026";
constexpr uint32_t APP_LINK_TIMEOUT_MS = 10000;
constexpr uint32_t WIFI_RESTART_DELAY_MS = 750;

// ---- Experiment-specific constants: calibrate these with water before use ----
constexpr float KP = 28.0f;                 // PWM counts per (mL/min) error
constexpr float KI = 4.0f;                  // PWM counts per (mL/min*s) error
constexpr float KD = 2.0f;                  // PWM counts per (mL/min)/s error
constexpr uint8_t MIN_PWM = 55;             // experimentally find the pump's reliable start value
constexpr uint8_t MAX_PWM = 180;            // local hardware rate clamp; never exceed without revalidation
constexpr float LOCAL_MAX_FLOW_ML_MIN = 5.0f;
constexpr float DEFAULT_DROPS_PER_ML = 20.0f; // REPLACE using CAL_RESULT measurement
constexpr float DEFAULT_TARGET_FLOW_ML_MIN = 2.0f;
constexpr uint32_t SENSOR_TIMEOUT_MS = 12000;
constexpr float OCCLUSION_PRESSURE_KPA = 30.0f; // This module measures 0–40 kPa.
// Calibrate counts-per-kPa with a known pressure reference before using displayed kPa as a measurement.
constexpr float HX710B_COUNTS_PER_KPA = 100000.0f;
constexpr uint32_t HX710B_READY_TIMEOUT_MS = 120;
constexpr uint32_t CONTROL_PERIOD_MS = 250;
constexpr uint32_t DROP_DEBOUNCE_US = 2500;
constexpr uint32_t NETWORK_COMMAND_TIMEOUT_MS = 30000;
constexpr uint32_t STARTUP_NO_FLOW_GRACE_MS = 4000;
constexpr uint32_t DOSE_TARGET_DROPS = 3;  // Stop and sound the alarm after this many detected drops.
constexpr uint32_t WDT_TIMEOUT_S = 4;
constexpr uint32_t PWM_FREQ_HZ = 18000;
constexpr uint8_t PWM_BITS = 8;

// Calibration table is a worksheet starter, not a pump model. Populate during water tests.

CalibrationPoint calibrationTable[] = {
  {60, 0.0f, 0.0f}, {100, 0.0f, 0.0f}, {140, 0.0f, 0.0f}, {180, 0.0f, 0.0f}
};
constexpr size_t CAL_POINTS = sizeof(calibrationTable) / sizeof(calibrationTable[0]);




volatile uint32_t totalDrops = 0;
volatile uint32_t windowDrops = 0;
volatile uint32_t lastDropUs = 0;
volatile uint32_t lastDropMs = 0;
PumpState state = IDLE;
FaultReason fault = NO_FAULT;
Preferences preferences;
WebServer server(80);
Adafruit_SSD1306 display(128, 64, &Wire, -1);
Adafruit_INA219 ina219;
bool oledPresent = false;
bool ina219Present = false;
float pumpBusVoltageV = 0.0f;
float pumpCurrentMa = 0.0f;

float targetFlowMlMin = DEFAULT_TARGET_FLOW_ML_MIN;
float dropsPerMl = DEFAULT_DROPS_PER_ML;
float measuredDropsSec = 0.0f, measuredFlowMlMin = 0.0f;
float pressureKpa = 0.0f;
float integral = 0.0f, priorError = 0.0f;
uint8_t pwmDuty = 0;
uint8_t knobMaxPwm = MAX_PWM;
int32_t hx710bZeroRaw = 0;
bool hx710bPresent = false;
uint32_t lastControlMs = 0, runStartedMs = 0;
// A recent USB command is the only truthful indication available that the USB control app is communicating.
uint32_t lastUsbCommandMs = 0;
// Updated by an app status poll or command. This indicates a real app exchange, not merely Wi-Fi power.
uint32_t lastAppContactMs = 0;
uint32_t runStartDrops = 0;
uint32_t calibrationStartDrops = 0, calibrationStartMs = 0;
uint8_t calibrationPwm = 0;
String wifiApSsid = DEFAULT_WIFI_AP_SSID;
bool wifiRestartPending = false;
uint32_t wifiRestartAtMs = 0;
// This bench controller remains local: the companion app uses the ESP32's own Wi-Fi network.

const char *stateName(PumpState s) {
  switch (s) { case IDLE:return "IDLE"; case CALIBRATING:return "CALIBRATING"; case RUNNING:return "RUNNING"; case PAUSED:return "PAUSED"; case COMPLETE:return "COMPLETE"; case OCCLUSION_ALARM:return "OCCLUSION_ALARM"; default:return "FAULT"; }
}
const char *faultName(FaultReason f) {
  switch (f) { case NO_FAULT:return "NONE"; case STOP_PRESSED:return "PHYSICAL_STOP"; case OCCLUSION:return "OCCLUSION"; case NO_FLOW:return "NO_FLOW_OR_AIR"; case PRESSURE_SENSOR_FAULT:return "PRESSURE_SENSOR"; case IR_SENSOR_FAULT:return "IR_SENSOR"; default:return "NETWORK_TIMEOUT"; }
}

String makeStatusJson() {
  uint32_t doseDrops = 0;
  if (state == RUNNING || state == CALIBRATING || state == PAUSED || state == COMPLETE) {
    noInterrupts(); doseDrops = totalDrops - runStartDrops; interrupts();
  }
  doseDrops = min(doseDrops, DOSE_TARGET_DROPS);
  const bool appActive = lastAppContactMs != 0 && millis() - lastAppContactMs <= APP_LINK_TIMEOUT_MS;
  String json = "{";
  json += "\"state\":\"" + String(stateName(state)) + "\",";
  json += "\"fault\":\"" + String(faultName(fault)) + "\",";
  json += "\"appLinkActive\":" + String(appActive ? "true" : "false") + ",";
  json += "\"wifiClients\":" + String(WiFi.softAPgetStationNum()) + ",";
  json += "\"wifiName\":\"" + wifiApSsid + "\",";
  json += "\"targetDrops\":" + String(DOSE_TARGET_DROPS) + ",";
  json += "\"dropsDelivered\":" + String(doseDrops) + ",";
  json += "\"dropsRemaining\":" + String(DOSE_TARGET_DROPS - doseDrops) + ",";
  json += "\"targetFlowMlMin\":" + String(targetFlowMlMin, 3) + ",";
  json += "\"flowMlMin\":" + String(measuredFlowMlMin, 3) + ",";
  json += "\"dropsPerSecond\":" + String(measuredDropsSec, 3) + ",";
  json += "\"pwm\":" + String(pwmDuty) + ",";
  json += "\"motorPercent\":" + String((uint8_t)roundf(100.0f * pwmDuty / 255.0f)) + ",";
  json += "\"motorVoltageEstimateV\":" + String(5.0f * pwmDuty / 255.0f, 2) + ",";
  json += "\"busVoltageV\":" + String(pumpBusVoltageV, 2) + ",";
  json += "\"currentMa\":" + String(pumpCurrentMa, 0) + ",";
  json += "\"pressureKpa\":" + String(pressureKpa, 2) + ",";
  json += "\"pressureSensorPresent\":" + String(hx710bPresent ? "true" : "false") + ",";
  json += "\"currentMonitorPresent\":" + String(ina219Present ? "true" : "false") + ",";
  json += "\"physicalStopPressed\":" + String(physicalStopPressed() ? "true" : "false");
  json += "}";
  return json;
}

void sendCorsHeaders() {
  server.sendHeader("Access-Control-Allow-Origin", "*");
  server.sendHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
  server.sendHeader("Access-Control-Allow-Headers", "Content-Type");
}
void handleStatusApi() {
  lastAppContactMs = millis();
  sendCorsHeaders();
  server.send(200, "application/json", makeStatusJson());
}
void handleCommandApi() {
  lastAppContactMs = millis();
  String command = server.hasArg("command") ? server.arg("command") : server.arg("plain");
  // Accept either a form field (command=START) or {"command":"START"} JSON.
  if (command.startsWith("{")) {
    int key = command.indexOf("\"command\"");
    int colon = command.indexOf(':', key);
    int firstQuote = command.indexOf('"', colon + 1);
    int lastQuote = command.indexOf('"', firstQuote + 1);
    command = (key >= 0 && colon >= 0 && firstQuote >= 0 && lastQuote > firstQuote) ? command.substring(firstQuote + 1, lastQuote) : "";
  }
  handleCommand(command, true);
  sendCorsHeaders();
  server.send(200, "application/json", makeStatusJson());
}
void handleApiOptions() { sendCorsHeaders(); server.send(204); }
bool isValidWifiName(const String &name) {
  if (name.length() < 1 || name.length() > 31) return false;
  for (size_t i = 0; i < name.length(); ++i) {
    char c = name.charAt(i);
    if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
          (c >= '0' && c <= '9') || c == '-' || c == '_')) return false;
  }
  return true;
}

void restartWirelessAp() {
  WiFi.softAPdisconnect(true);
  delay(100);
  WiFi.mode(WIFI_AP);
  bool started = WiFi.softAP(wifiApSsid.c_str(), WIFI_AP_PASSWORD, 6, false, 4);
  lastAppContactMs = 0;
  if (started) Serial.printf("Wi-Fi renamed: %s ready at http://%s/api/status\n", wifiApSsid.c_str(), WiFi.softAPIP().toString().c_str());
  else Serial.println("Wi-Fi AP could not restart; USB Serial control remains available.");
}

void startWirelessApi() {
  WiFi.mode(WIFI_AP);
  bool started = WiFi.softAP(wifiApSsid.c_str(), WIFI_AP_PASSWORD, 6, false, 4);
  server.on("/api/status", HTTP_GET, handleStatusApi);
  server.on("/api/command", HTTP_POST, handleCommandApi);
  server.on("/api/status", HTTP_OPTIONS, handleApiOptions);
  server.on("/api/command", HTTP_OPTIONS, handleApiOptions);
  server.onNotFound([]() { sendCorsHeaders(); server.send(404, "application/json", "{\"error\":\"not found\"}"); });
  server.begin();
  if (started) Serial.printf("Wi-Fi AP %s ready at http://%s/api/status\n", wifiApSsid.c_str(), WiFi.softAPIP().toString().c_str());
  else Serial.println("Wi-Fi AP could not start; USB Serial control remains available.");
}

void IRAM_ATTR onDrop() {
  uint32_t now = micros();
  if ((uint32_t)(now - lastDropUs) >= DROP_DEBOUNCE_US) {
    totalDrops++; windowDrops++; lastDropUs = now; lastDropMs = millis();
  }
}

void writePumpPwm(uint8_t duty) {
  ledcWrite(PUMP_PWM_PIN, duty);
}
void pumpOff() {
  writePumpPwm(0); pwmDuty = 0;
  digitalWrite(AIN1_PIN, LOW); digitalWrite(AIN2_PIN, LOW);
}
void alarmOutputs(bool active) {
  digitalWrite(ALARM_LED_PIN, active ? HIGH : LOW);
  digitalWrite(BUZZER_PIN, active ? HIGH : LOW);
}
void latchFault(FaultReason why) {
  pumpOff(); fault = why;
  state = (why == OCCLUSION) ? OCCLUSION_ALARM : FAULT;
  alarmOutputs(true);
  Serial.printf("ALARM,%s\n", faultName(why));
}
bool physicalStopPressed() { return digitalRead(STOP_PIN) == LOW; }

// The knob sets an upper speed limit through PWM; it never carries pump current.
void updateSpeedLimit() {
  int raw = analogRead(SPEED_POT_PIN);
  knobMaxPwm = (uint8_t)constrain(map(raw, 0, 4095, MIN_PWM, MAX_PWM), MIN_PWM, MAX_PWM);
}

// HX710B is HX711-style: DOUT goes LOW when a 24-bit sample is ready; SCK clocks it out.
bool readHx710bRaw(int32_t &raw) {
  uint32_t started = millis();
  while (digitalRead(HX710B_DOUT_PIN) == HIGH) {
    if (millis() - started >= HX710B_READY_TIMEOUT_MS) return false;
    delay(1);
  }
  uint32_t value = 0;
  for (uint8_t bit = 0; bit < 24; ++bit) {
    digitalWrite(HX710B_SCK_PIN, HIGH); delayMicroseconds(1);
    value = (value << 1) | (digitalRead(HX710B_DOUT_PIN) ? 1U : 0U);
    digitalWrite(HX710B_SCK_PIN, LOW); delayMicroseconds(1);
  }
  // One extra pulse selects channel A at gain 128 for the next conversion.
  digitalWrite(HX710B_SCK_PIN, HIGH); delayMicroseconds(1);
  digitalWrite(HX710B_SCK_PIN, LOW); delayMicroseconds(1);
  if (value & 0x800000UL) value |= 0xFF000000UL;
  raw = (int32_t)value;
  return true;
}

float readPressureKpa(bool &valid) {
  int32_t raw = 0;
  valid = hx710bPresent && readHx710bRaw(raw);
  if (!valid) return 0.0f;
  float kpa = (raw - hx710bZeroRaw) / HX710B_COUNTS_PER_KPA;
  return constrain(kpa, 0.0f, 40.0f);
}

bool conditionsSafe() {
  bool pressureValid = false;
  float p = readPressureKpa(pressureValid);
  return !physicalStopPressed() && pressureValid && p < OCCLUSION_PRESSURE_KPA;
}
void beginRun() {
  if (!conditionsSafe()) { latchFault(physicalStopPressed() ? STOP_PRESSED : PRESSURE_SENSOR_FAULT); return; }
  integral = 0; priorError = 0; runStartedMs = millis();
  noInterrupts(); runStartDrops = totalDrops; windowDrops = 0; interrupts();
  state = RUNNING; fault = NO_FAULT; alarmOutputs(false);
  updateSpeedLimit();
  pwmDuty = MIN_PWM; digitalWrite(AIN1_PIN, HIGH); digitalWrite(AIN2_PIN, LOW); writePumpPwm(pwmDuty);
}
void pausePump() { pumpOff(); if (state == RUNNING || state == CALIBRATING) state = PAUSED; }

void completeDose() {
  pumpOff();
  state = COMPLETE;
  alarmOutputs(true);  // Active buzzer and LED remain on until acknowledged.
  Serial.printf("COMPLETE,%lu drops: pump stopped\n", (unsigned long)DOSE_TARGET_DROPS);
}

void controlAndSafety() {
  if (physicalStopPressed()) { if (state != FAULT || fault != STOP_PRESSED) latchFault(STOP_PRESSED); return; }
  if (state == RUNNING) {
    noInterrupts(); uint32_t dropsThisRun = totalDrops - runStartDrops; interrupts();
    if (dropsThisRun >= DOSE_TARGET_DROPS) { completeDose(); return; }
  }
  bool pressureValid = false;
  pressureKpa = readPressureKpa(pressureValid);
  if (!pressureValid) { if (state == RUNNING || state == CALIBRATING) latchFault(PRESSURE_SENSOR_FAULT); return; }
  if (pressureKpa >= OCCLUSION_PRESSURE_KPA && (state == RUNNING || state == CALIBRATING)) { latchFault(OCCLUSION); return; }
  if (state != RUNNING) return;
  updateSpeedLimit();
  uint32_t now = millis();
  if (pwmDuty >= MIN_PWM && now - runStartedMs > STARTUP_NO_FLOW_GRACE_MS && now - lastDropMs > SENSOR_TIMEOUT_MS) { latchFault(NO_FLOW); return; }
  if (now - lastControlMs < CONTROL_PERIOD_MS) return;
  float dt = (now - lastControlMs) / 1000.0f; lastControlMs = now;
  noInterrupts(); uint32_t drops = windowDrops; windowDrops = 0; interrupts();
  measuredDropsSec = drops / dt;
  measuredFlowMlMin = measuredDropsSec * 60.0f / dropsPerMl;
  float error = targetFlowMlMin - measuredFlowMlMin;
  float candidateIntegral = constrain(integral + error * dt, -20.0f, 20.0f);
  float derivative = (error - priorError) / dt;
  float output = KP * error + KI * candidateIntegral + KD * derivative;
  float requested = pwmDuty + output;
  uint8_t constrained = (uint8_t)constrain(requested, (float)MIN_PWM, (float)knobMaxPwm);
  // Only retain the integral when saturation is not forcing the output farther outward.
  if ((requested == constrained) || (requested > knobMaxPwm && error < 0) || (requested < MIN_PWM && error > 0)) integral = candidateIntegral;
  priorError = error; pwmDuty = constrained; writePumpPwm(pwmDuty);
}

void handleCommand(String command, bool remote) {
  command.trim();
  String originalCommand = command;
  command.toUpperCase();
  if (remote) lastAppContactMs = millis();
  else lastUsbCommandMs = millis();
  if (command.startsWith("SET_WIFI_NAME ")) {
    String requestedName = originalCommand.substring(14);
    requestedName.trim();
    if ((state == IDLE || (state == PAUSED && fault == NO_FAULT && conditionsSafe())) && isValidWifiName(requestedName)) {
      wifiApSsid = requestedName;
      preferences.putString("wifiName", wifiApSsid);
      wifiRestartPending = true;
      wifiRestartAtMs = millis() + WIFI_RESTART_DELAY_MS;
      Serial.printf("Wi-Fi rename accepted: reconnect to %s after the network restarts.\n", wifiApSsid.c_str());
    } else Serial.println("REJECTED: Wi-Fi name needs 1-31 letters, numbers, hyphens, or underscores; pump must be idle or safely paused");
  } else if (command.startsWith("SET_FLOW ")) {
    float requested = command.substring(9).toFloat();
    if (requested > 0.0f && requested <= LOCAL_MAX_FLOW_ML_MIN) targetFlowMlMin = requested;
    else Serial.println("REJECTED: flow exceeds local clamp or is invalid");
  } else if (command == "START") {
    if (state == IDLE || state == PAUSED) beginRun();
    else Serial.println("REJECTED: acknowledge faults, then explicitly RESUME");
  } else if (command == "PAUSE") pausePump();
  else if (command == "STOP") latchFault(STOP_PRESSED);
  else if (command == "ACK_ALARM") {
    if (state == COMPLETE) { state = PAUSED; alarmOutputs(false); Serial.println("DOSE COMPLETE: acknowledged; now use START for the next three-drop dose"); }
    else if ((state == FAULT || state == OCCLUSION_ALARM) && conditionsSafe()) { fault = NO_FAULT; state = PAUSED; alarmOutputs(false); Serial.println("ACKNOWLEDGED: now use RESUME"); }
    else Serial.println("REJECTED: release STOP and remove pressure/sensor fault first");
  } else if (command == "RESUME") {
    if (state == PAUSED && fault == NO_FAULT && conditionsSafe()) beginRun();
    else Serial.println("REJECTED: safety conditions are not verified");
  } else if (command.startsWith("CAL ")) {
    if (state == IDLE || state == PAUSED) { calibrationPwm = constrain(command.substring(4).toInt(), MIN_PWM, MAX_PWM); calibrationStartDrops = totalDrops; calibrationStartMs = millis(); state = CALIBRATING; fault = NO_FAULT; alarmOutputs(false); digitalWrite(AIN1_PIN,HIGH); digitalWrite(AIN2_PIN,LOW); pwmDuty = calibrationPwm; writePumpPwm(pwmDuty); Serial.println("CAL running: collect water, then send CAL_RESULT <measured_mL>"); }
  } else if (command.startsWith("CAL_RESULT ")) {
    if (state == CALIBRATING) { float volume = command.substring(11).toFloat(); uint32_t d = totalDrops - calibrationStartDrops; float sec = (millis()-calibrationStartMs)/1000.0f; pumpOff(); state = PAUSED; if (volume > 0.0f && sec > 0.0f) { dropsPerMl = d / volume; preferences.putFloat("dropsMl", dropsPerMl); Serial.printf("CAL,%u,%.3f drops/s,%.3f mL/min,%.3f drops/mL SAVED\n", calibrationPwm, d/sec, volume*60.0f/sec, dropsPerMl); } }
  } else if (command == "CAL_STOP") pausePump();
  else if (command == "STATUS") { /* printed by periodic report */ }
  else Serial.println("Unknown command");
}
void drawStatusDisplay() {
  if (!oledPresent) return;

  uint32_t doseDrops = 0;
  if (state == RUNNING || state == CALIBRATING || state == PAUSED || state == COMPLETE) {
    noInterrupts();
    doseDrops = totalDrops - runStartDrops;
    interrupts();
  }
  doseDrops = min(doseDrops, DOSE_TARGET_DROPS);
  uint32_t dropsRemaining = DOSE_TARGET_DROPS - doseDrops;
  uint32_t elapsedSeconds = (state == RUNNING || state == CALIBRATING || state == PAUSED || state == COMPLETE)
    ? (millis() - runStartedMs) / 1000UL : 0;
  float estimatedMotorV = 5.0f * pwmDuty / 255.0f;
  uint8_t motorPercent = (uint8_t)roundf(100.0f * pwmDuty / 255.0f);
  bool appControlActive = lastAppContactMs != 0 && (millis() - lastAppContactMs) <= APP_LINK_TIMEOUT_MS;

  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);

  // Top: estimated motor voltage, recent USB-control activity, next-drop estimate.
  display.setCursor(0, 0);  display.printf("M%.1fV", estimatedMotorV);
  display.setCursor(45, 0); display.print(appControlActive ? "APP OK" : "APP --");
  display.setCursor(91, 0);
  if (state == RUNNING && measuredDropsSec > 0.05f) display.printf("N%.1fs", 1.0f / measuredDropsSec);
  else display.print("N--s");
  display.drawFastHLine(0, 9, 128, SSD1306_WHITE);

  // Middle: target drops; centre panel split into remaining and PWM speed; delivered drops.
  display.drawFastVLine(42, 10, 33, SSD1306_WHITE);
  display.drawFastVLine(87, 10, 33, SSD1306_WHITE);
  display.drawFastHLine(43, 26, 43, SSD1306_WHITE);
  display.setCursor(2, 12);  display.print("TARGET");
  display.setCursor(48, 12); display.print("LEFT");
  display.setCursor(91, 12); display.print("DONE");
  display.setTextSize(2);
  display.setCursor(14, 20);  display.printf("%lu", (unsigned long)DOSE_TARGET_DROPS);
  display.setCursor(58, 17);  display.printf("%lu", (unsigned long)dropsRemaining);
  display.setCursor(102, 20); display.printf("%lu", (unsigned long)doseDrops);
  display.setTextSize(1);
  display.setCursor(47, 29); display.printf("SPD %u%%", motorPercent);

  // Bottom: elapsed time; motor speed plus measured current; pressure or alarm status.
  display.drawFastHLine(0, 43, 128, SSD1306_WHITE);
  display.drawFastVLine(42, 44, 20, SSD1306_WHITE);
  display.drawFastVLine(87, 44, 20, SSD1306_WHITE);
  display.setCursor(1, 46);  display.print("TIME");
  display.setCursor(45, 46); display.print("SPD / I");
  display.setCursor(90, 46); display.print(fault != NO_FAULT ? "ALARM" : "PRESS");
  display.setCursor(1, 56);  display.printf("%lus", (unsigned long)elapsedSeconds);
  display.setCursor(45, 56);
  if (ina219Present) display.printf("%u%% %.0fm", motorPercent, pumpCurrentMa);
  else display.printf("%u%% --m", motorPercent);
  display.setCursor(90, 56);
  if (fault != NO_FAULT) display.print("FAULT");
  else if (state == COMPLETE) display.print("DONE");
  else display.printf("%.0fkPa", pressureKpa);
  display.display();
}

void setup() {
  Serial.begin(115200);
  Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN);
  oledPresent = display.begin(SSD1306_SWITCHCAPVCC, OLED_I2C_ADDRESS);
  if (oledPresent) { display.clearDisplay(); display.display(); }
  ina219Present = ina219.begin();
  if (!ina219Present) Serial.println("INA219 not detected: OLED current and voltage fields will show monitor missing.");
  pinMode(AIN1_PIN, OUTPUT); pinMode(AIN2_PIN, OUTPUT); pinMode(ALARM_LED_PIN, OUTPUT); pinMode(BUZZER_PIN, OUTPUT);
  pinMode(STOP_PIN, INPUT); pinMode(DROP_PIN, INPUT); pinMode(SPEED_POT_PIN, INPUT); // STOP uses its external pull-up; the 3.3 V LM393 module drives DROP_PIN directly.
  pinMode(HX710B_DOUT_PIN, INPUT);
  pinMode(HX710B_SCK_PIN, OUTPUT);
  digitalWrite(HX710B_SCK_PIN, LOW);
  int64_t zeroSum = 0;
  int32_t zeroSample = 0;
  uint8_t zeroSamples = 0;
  for (uint8_t i = 0; i < 10; ++i) {
    if (readHx710bRaw(zeroSample)) { zeroSum += zeroSample; ++zeroSamples; }
  }
  hx710bPresent = zeroSamples >= 5;
  if (hx710bPresent) hx710bZeroRaw = (int32_t)(zeroSum / zeroSamples);
  ledcAttach(PUMP_PWM_PIN, PWM_FREQ_HZ, PWM_BITS);
  pumpOff(); alarmOutputs(false);
  attachInterrupt(digitalPinToInterrupt(DROP_PIN), onDrop, FALLING);
  preferences.begin("smartpump", false);
  dropsPerMl = preferences.getFloat("dropsMl", DEFAULT_DROPS_PER_ML);
  wifiApSsid = preferences.getString("wifiName", DEFAULT_WIFI_AP_SSID);
  if (!isValidWifiName(wifiApSsid)) wifiApSsid = DEFAULT_WIFI_AP_SSID;
#if defined(ESP32)
  #if ESP_ARDUINO_VERSION_MAJOR >= 3
    esp_task_wdt_config_t cfg = { .timeout_ms = WDT_TIMEOUT_S * 1000, .idle_core_mask = 0, .trigger_panic = true };
    esp_task_wdt_init(&cfg);
  #else
    esp_task_wdt_init(WDT_TIMEOUT_S, true);
  #endif
  esp_task_wdt_add(NULL);
#endif
  lastControlMs = millis(); lastDropMs = millis();
  startWirelessApi();
  if (hx710bPresent) Serial.println("HX710B zeroed with its hose open to air.");
  else Serial.println("HX710B not detected: pump will remain locked out for safety.");
  Serial.println("Water-only smart pump ready. Pump defaults OFF. Send STATUS or SET_FLOW then START.");
}
void loop() {
#if defined(ESP32)
  esp_task_wdt_reset();
#endif
  server.handleClient();
  if (wifiRestartPending && (int32_t)(millis() - wifiRestartAtMs) >= 0) {
    wifiRestartPending = false;
    restartWirelessAp();
  }
  if (Serial.available()) handleCommand(Serial.readStringUntil('\n'), false);
  controlAndSafety();
  static uint32_t lastReport = 0;
  if (millis() - lastReport >= 1000) {
    lastReport = millis();
    if (ina219Present) {
      pumpBusVoltageV = ina219.getBusVoltage_V();
      pumpCurrentMa = ina219.getCurrent_mA();
    }
    float error = targetFlowMlMin - measuredFlowMlMin;
    Serial.printf("state=%s fault=%s mode=local target=%.3f flow=%.3f drops/s=%.3f pressure=%.2f V=%.2f I=%.0fmA pwm=%u knobMax=%u error=%.3f\n", stateName(state), faultName(fault), targetFlowMlMin, measuredFlowMlMin, measuredDropsSec, pressureKpa, pumpBusVoltageV, pumpCurrentMa, pwmDuty, knobMaxPwm, error);
    drawStatusDisplay();
  }
  delay(1);
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik