Community project

Smart Water-Saving Faucet

Photo of Smart Water-Saving Faucet
Generated with AI

Darling Pearl Maningo

Published November 13, 2026

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

Wiring diagram for Smart Water-Saving Faucet
Loading diagram…

Gather all the parts

QtyComponent
1

Adafruit 828 flow meter (bench testing only)

5V sensor, 450 nominal pulses/liter, 1–30 L/min. Not verified potable-water safe.

1

Resistor

10 kohm

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

1

Resistor

15 kohm

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

1

Adafruit 3190 DRV8871 breakout

12V H-bridge with internal inductive protection. Add 150k across onboard 30k Rlim for approximately 2.4A limit.

1

Resistor

150 kohm

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

1

U.S. Solid USS-YSV00072 normally-open valve

12V 2.11A stainless steel valve. Power closes; loss of power opens. Manufacturer claims drinking-water suitability, not independently verified here.

1

Enclosed regulated 12V DC 3A adapter

Safety-certified isolated wall adapter; only low-voltage output is wired.

1

Push Button

Momentary push button switch

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"

FunctionflowESP32
powerRED5V
groundBLACKGND
digitalYELLOW → Resistor P1EXT

2. Connections between "dividerTop" and "ESP32"

FunctiondividerTopESP32
digitalP2GPIO 27

3. Connections between "dividerBottom" and "ESP32"

FunctiondividerBottomESP32
digitalP1 → Resistor P2EXT
groundP2GND

4. Connections between "supply" and "ESP32"

FunctionsupplyESP32
powerPOS → Adafruit 3190 DRV8871 breakout VMEXT
groundNEGGND

5. Connections between "driver" and "ESP32"

FunctiondriverESP32
groundGNDGND
digitalIN1GPIO 25
digitalIN2GPIO 26
powerOUT1 → U.S. Solid USS-YSV00072 normally-open valve WIRE1EXT
powerOUT2 → U.S. Solid USS-YSV00072 normally-open valve WIRE2EXT

6. Connections between "limit" and "ESP32"

FunctionlimitESP32
digitalP1 → Adafruit 3190 DRV8871 breakout RLIMEXT
groundP2GND

7. Connections between "override" and "ESP32"

FunctionoverrideESP32
digitalSIGNALGPIO 33
groundGNDGND

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.

Open in Schematik