This smart faucet controller uses an ESP32 to monitor water flow and automatically shut off the valve after a set duration, helping reduce household water waste. Built around the Adafruit flow meter for accurate consumption tracking and the DRV8871 motor driver to control a solenoid valve, the system logs daily usage and can sync data to a remote endpoint for analysis.
The guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions starting with bench testing of the flow meter. Firmware is included with WiFi connectivity, local web interface, and configurable thresholds for automatic shutoff, allowing customization for different faucets and water-saving goals.
Wiring diagram

Gather all the parts
Assemble it in 4 steps
1. Start with a bench water test
This is a prototype, not a certified household safety system. Adafruit 828 has no verified drinking-water approval here. Test with non-drinking cold water in a contained loop. Before household installation, replace it with a certified potable-water meter and have a plumber verify pressure, threads, backflow protection, and a manual bypass.
- Fail-open means power loss also disables leak protection. The 1–30 L/min meter cannot reliably detect tiny leaks or very low flow. Never mix NPS and NPT threads without suitable fittings.
2. Connect the meter safely
Red → ESP32 5V (power), black → GND. Yellow → 10 kohm resistor → GPIO27 (pulse data). Connect 15 kohm from GPIO27 to GND. This reduces a 5V pulse to about 3V. Power the ESP32 by USB only.
3. Prepare the valve driver
Solder the breakout terminal blocks. Solder the 150 kohm resistor across the onboard 30 kohm Rlim resistor (RLIM to GND), giving about 25 kohm total and approximately 2.4A current limit. IN1 → GPIO25; IN2 → GPIO26; GND → ESP32 GND. Valve wires go to OUT1 and OUT2.
- Coil and driver can become hot. Do not put valve current through a breadboard. Shutdown lasts at most 60 seconds, then automatic closing is disabled until restarted or re-enabled. This is not unattended flood protection.
4. Connect isolated valve power and override
With power unplugged, connect adapter positive to VM and negative to GND. Join that GND to ESP32 GND. Button SIGNAL → GPIO33 and button GND → GND. Pressing it disables automatic closing and releases the valve. Unplugging valve power also releases it.
- Never connect 12V to ESP32 5V, 3V3, or GPIO pins. Keep adapters and electronics dry and above plumbing. Use only enclosed safety-certified adapters; never wire mains electricity.
Review all connections
1. Connections between "flow" and "ESP32"
2. Connections between "dividerTop" and "ESP32"
3. Connections between "dividerBottom" and "ESP32"
4. Connections between "supply" and "ESP32"
5. Connections between "driver" and "ESP32"
6. Connections between "limit" and "ESP32"
7. Connections between "override" and "ESP32"
Deploy the firmware
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <Preferences.h>
#include <LittleFS.h>
#include <time.h>
// Configure these before deployment. Leave endpoint empty for local-only use.
const char *SSID="", *PASS="", *ENDPOINT="", *TOKEN="", *ROOT_CA="";
const char *TZ_RULE="UTC0"; // Set household timezone including daylight-saving rules.
WebServer web(80); Preferences prefs;
volatile uint32_t pulses=0, edgeUs=0;
portMUX_TYPE mux=portMUX_INITIALIZER_UNLOCKED;
void IRAM_ATTR pulse(){uint32_t now=micros(); if(now-edgeUs<1000)return; edgeUs=now; portENTER_CRITICAL_ISR(&mux); pulses++; portEXIT_CRITICAL_ISR(&mux);}
float ppl=450, liters=0, seconds=0, baseL=0, baseS=0, todayL=0;
uint32_t samples=0, lastTick=0, lastFlow=0, startMs=0, closeMs=0, seq=0;
int bursts=0, rewards=0, streak=0, dayKey=0, todayUses=0;
bool active=false, flowing=false, closed=false, bypass=false, invalid=false, fsOK=false;
time_t startTime=0; String apPass, userId; float history[7]={};
String day(){time_t t=time(nullptr); if(t<1700000000)return "unsynced"; tm z; localtime_r(&t,&z); char b[16]; strftime(b,sizeof(b),"%Y-%m-%d",&z); return b;}
void valve(bool close){closed=close; digitalWrite(26,LOW);digitalWrite(25,close?HIGH:LOW);if(close)closeMs=millis();}
void saveDay(){prefs.putInt("uses",todayUses);prefs.putFloat("today",todayL);prefs.putInt("rewards",rewards);prefs.putInt("streak",streak);prefs.putInt("day",dayKey);prefs.putBytes("history",history,sizeof(history));}
void calendar(){time_t t=time(nullptr);if(t<1700000000)return;tm z;localtime_r(&t,&z);int key=(z.tm_year+1900)*1000+z.tm_yday;
if(dayKey && key!=dayKey){bool consecutive=(key==dayKey+1)||(z.tm_yday==0 && dayKey%1000>=364 && key/1000==dayKey/1000+1);streak=(consecutive && todayL>0 && samples>=30 && todayUses>0 && todayL/todayUses<=baseL)?streak+1:0;for(int i=6;i>0;i--)history[i]=history[i-1];history[0]=todayL;todayL=0;todayUses=0;rewards=0;}if(key!=dayKey){dayKey=key;saveDay();}}
void record(){
bool credible=!invalid && liters>=0.05 && seconds>=1;
bool learned=samples>=30; bool efficient=credible&&learned&&liters<=baseL*.7f&&seconds<=baseS*.7f;
bool interrupted=credible&&bursts>=2&&bursts<=4&&seconds<=30&&(millis()-startMs)<=90000;
bool credit=credible&&learned&&(efficient||interrupted)&&rewards<3&&startTime>=1700000000;
if(credit)rewards++;
seq++;prefs.putUInt("seq",seq);
String json="{\"id\":\""+userId+"-"+String(seq)+"\",\"start_epoch\":"+String((unsigned long)startTime)+",\"date\":\""+day()+"\",\"liters\":"+String(liters,4)+",\"flow_seconds\":"+String(seconds,2)+",\"credible\":"+(credible?"true":"false")+",\"efficient\":"+(efficient?"true":"false")+",\"interrupted_flow_candidate\":"+(interrupted?"true":"false")+",\"reward_candidate\":"+(credit?"true":"false")+"}";
if(fsOK){File f=LittleFS.open("/events.ndjson","a");if(f && f.size()<500000){f.println(json);f.flush();}else Serial.println("Storage full: event NOT saved; no unlimited offline guarantee");f.close();}
Serial.println(json);
if(credible){todayL+=liters;todayUses++;if(samples<30){baseL=(baseL*samples+liters)/(samples+1);baseS=(baseS*samples+seconds)/(samples+1);samples++;prefs.putFloat("bl",baseL);prefs.putFloat("bs",baseS);prefs.putUInt("n",samples);}saveDay();}
active=false;flowing=false;liters=seconds=0;bursts=0;invalid=false;
}
String status(){String s="{\"date\":\""+day()+"\",\"today_liters\":"+String(todayL,3)+",\"live_liters\":"+String(liters,3)+",\"live_seconds\":"+String(seconds,1)+",\"baseline_liters_per_use\":"+String(baseL,3)+",\"baseline_seconds_per_use\":"+String(baseS,1)+",\"learning_uses\":"+String(samples)+",\"daily_reward_candidates\":"+String(rewards)+",\"streak_estimate\":"+String(streak)+",\"valve_closed\":"+(closed?"true":"false")+",\"override\":"+(bypass?"true":"false")+",\"reminder\":"+((active&&seconds>30)?"true":"false")+",\"weekly_liters\":[";for(int i=0;i<7;i++){if(i)s+=",";s+=String(history[i],3);}return s+"]}";}
bool auth(){if(web.authenticate("taply",apPass.c_str()))return true;web.requestAuthentication();return false;}
void syncEvents(){if(!fsOK||WiFi.status()!=WL_CONNECTED||!strlen(ENDPOINT)||!strlen(ROOT_CA)||!strlen(TOKEN)||!String(ENDPOINT).startsWith("https://"))return;
uint32_t offset=prefs.getUInt("offset",0);File f=LittleFS.open("/events.ndjson","r");if(!f||offset>=f.size()){f.close();return;}f.seek(offset);String line=f.readStringUntil('\n');uint32_t next=f.position();f.close();
WiFiClientSecure tls;tls.setCACert(ROOT_CA);HTTPClient http;http.setTimeout(1500);if(http.begin(tls,ENDPOINT)){http.addHeader("Content-Type","application/json");http.addHeader("Authorization",String("Bearer ")+TOKEN);int result=http.POST(line);if(result>=200&&result<300)prefs.putUInt("offset",next);http.end();}}
void setup(){Serial.begin(115200);pinMode(25,OUTPUT);pinMode(26,OUTPUT);valve(false);pinMode(33,INPUT_PULLUP);pinMode(27,INPUT);attachInterrupt(27,pulse,RISING);
prefs.begin("taply",false);ppl=prefs.getFloat("ppl",450);seq=prefs.getUInt("seq",0);baseL=prefs.getFloat("bl",0);baseS=prefs.getFloat("bs",0);samples=prefs.getUInt("n",0);todayUses=prefs.getInt("uses",0);todayL=prefs.getFloat("today",0);dayKey=prefs.getInt("day",0);rewards=prefs.getInt("rewards",0);streak=prefs.getInt("streak",0);prefs.getBytes("history",history,sizeof(history));
userId=prefs.getString("id",String((uint32_t)ESP.getEfuseMac(),HEX));apPass=prefs.getString("key","");if(apPass.length()<12){apPass=String(esp_random(),HEX)+String(esp_random(),HEX);prefs.putString("key",apPass);}
fsOK=LittleFS.begin(false);if(!fsOK){Serial.println("Initializing new storage");fsOK=LittleFS.format()&&LittleFS.begin(false);}WiFi.mode(WIFI_AP_STA);WiFi.softAP(("Taply-"+userId).c_str(),apPass.c_str());if(strlen(SSID))WiFi.begin(SSID,PASS);configTzTime(TZ_RULE,"pool.ntp.org","time.nist.gov");Serial.println("AP password and web password: "+apPass);Serial.println("Dashboard: http://192.168.4.1 user taply");
web.on("/",[]{if(!auth())return;web.send(200,"text/html",R"HTML(<h1>Taply prototype</h1><p>Automatic measurement. Baseline learns 30 valid uses. Rewards are candidates, not game currency.</p><pre id="s"></pre><a href="/export">Export event history</a><p>Maintenance: POST /calibrate?ppl=450; POST /override; POST /delete. Physical button releases valve.</p><script>setInterval(async()=>{document.getElementById('s').textContent=JSON.stringify(await(await fetch('/status')).json(),null,2)},1000)</script>)HTML");});
web.on("/status",[]{if(auth())web.send(200,"application/json",status());});
web.on("/export",[]{if(!auth())return;File f=LittleFS.open("/events.ndjson","r");web.streamFile(f,"application/x-ndjson");f.close();});
web.on("/calibrate",HTTP_POST,[]{if(!auth())return;float v=web.arg("ppl").toFloat();if(active||v<10||v>100000){web.send(400,"text/plain","Idle only; pulses/liter must be 10..100000");return;}ppl=v;prefs.putFloat("ppl",ppl);web.send(200,"text/plain","Calibration saved");});
web.on("/override",HTTP_POST,[]{if(!auth())return;bypass=!bypass;valve(false);web.send(200,"text/plain",bypass?"Automatic closing disabled":"Automatic closing enabled");});
web.on("/delete",HTTP_POST,[]{if(!auth())return;valve(false);LittleFS.remove("/events.ndjson");prefs.clear();web.send(200,"text/plain","Local data deleted. Delete server data separately. Restarting.");delay(200);ESP.restart();});web.begin();}
void loop(){web.handleClient();uint32_t now=millis();if(digitalRead(33)==LOW){bypass=true;valve(false);}if(closed&&now-closeMs>60000){valve(false);bypass=true;} // never hold coil closed indefinitely
if(now-lastTick>=1000){uint32_t dt=now-lastTick;lastTick=now;uint32_t n;portENTER_CRITICAL(&mux);n=pulses;pulses=0;portEXIT_CRITICAL(&mux);calendar();float amount=n/ppl;
if(n){if(!active){active=true;startMs=now;startTime=time(nullptr);if(startTime<1700000000)startTime=0;}if(!flowing)bursts++;flowing=true;lastFlow=now;liters+=amount;seconds+=dt/1000.f;if(amount*60000/dt>30)invalid=true;}else flowing=false;
if(active&&!bypass&&!closed&&(seconds>=120||(samples>=30&&seconds>max(30.f,baseS*3))))valve(true);
if(active&&now-lastFlow>15000)record();
// Network work only between uses to keep shutoff responsive.
static uint32_t lastSync=0;if(!active&&now-lastSync>15000){lastSync=now;syncEvents();}}
delay(2);}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.




