Community project
Carwash Vendo Timer Dashboard
This carwash vendo timer dashboard turns an ESP32 microcontroller into a coin-operated control system for managing multiple wash bays or equipment. The system accepts coins via a 12V multi-coin acceptor, displays credits and machine status on a 20x4 LCD screen, and triggers four independent 12V loads through opto-isolated relays when customers select their wash cycle.
This guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions for mounting the low-voltage control electronics and connecting the coin acceptor, LCD display, push buttons, buzzer, and relay outputs. The included firmware handles coin debouncing, credit tracking, machine configuration, and a web-based admin interface for setting prices and cycle times—everything needed to deploy a functional carwash payment system.
Wiring diagram

Gather all the parts
Assemble it in 6 steps
1. Mount the low-voltage control parts
Put the ESP32 board, the 20×4 display, the four start buttons, the buzzer, the relay board, the coin-pulse opto-isolator, and both power supplies inside a dry plastic control enclosure. Keep the 12 V coin wiring and any machine wiring separated from the ESP32 wiring.
- Label the buttons 1 through 4 to match WATER 1, WATER 2, BLOWER, and SHAMPOO before wiring.
- Use a proper strain relief where the coin-slot and machine-control cables enter the enclosure.
- Do not put mains-voltage wires on a breadboard or beside the ESP32. The relay contacts must only be connected by a qualified person to the machine’s documented low-voltage enable input or a suitably rated, enclosed contactor system.
2. Wire the display and buzzer
Connect the display VCC to ESP32 3V3 (power), display GND to ESP32 GND (ground), display SDA to GPIO21 (data), and display SCL to GPIO22 (data). Connect the buzzer SIGNAL to GPIO26 (sound signal) and buzzer GND to ESP32 GND (ground).
- A 2004 I2C display must be powered at 3.3 V here so its SDA and SCL wires stay safe for the ESP32.
- If the screen lights but shows no words after flashing, its I2C address may be 0x3F instead of 0x27.
- Make sure VCC and GND are not swapped — swapped power can damage the display.
3. Wire the four start buttons
For each button, connect one terminal to GND and the other terminal to its ESP32 signal pin: button 1 to GPIO18 (start signal), button 2 to GPIO19 (start signal), button 3 to GPIO23 (start signal), and button 4 to GPIO25 (start signal).
- The code uses the ESP32’s built-in pull-up resistors, so no separate resistor is needed for these buttons.
- A quick tap counts exactly once.
- Do not connect a button signal wire to 5 V; the ESP32 input pins are 3.3 V only.
4. Wire the isolated coin input
Power the coin acceptor from the 12 V adapter: adapter +12V to coin slot +12V (power) and adapter GND to coin slot GND (ground). Connect the coin slot PULSE wire to the opto-isolator COIN+ input (coin signal), and connect opto-isolator COIN- back to the coin slot GND (return). On the safe ESP32 side, connect the opto-isolator VCC to 3V3 (power), GND to ESP32 GND (ground), and OUT to GPIO27 (coin signal).
- Set the coin acceptor to output one pulse for each accepted one-peso coin before testing.
- The opto-isolator keeps the 12 V coin slot electrically separated from the ESP32 input.
- Never connect the coin acceptor’s 12 V PULSE wire directly to GPIO27 — 12 V can permanently damage the ESP32.
5. Wire the relay control inputs
Connect the relay board VCC to regulated 5 V (power) and relay GND to the ESP32 GND (ground). Connect IN1 to GPIO13 (machine 1 control), IN2 to GPIO14 (machine 2 control), IN3 to GPIO16 (machine 3 control), and IN4 to GPIO17 (machine 4 control). Connect each relay COM and NO pair only to the matching machine’s low-voltage enable circuit: relay 1 for machine 1, relay 2 for machine 2, relay 3 for machine 3, and relay 4 for machine 4.
- Use a relay board explicitly marked as accepting 3.3 V logic triggers; some 5 V relay boards do not reliably recognize ESP32 signals.
- The software turns every relay off during startup and only allows one relay on at a time.
- Do not wire the ESP32 directly to a pump, motor, valve, or mains supply. Switching a high-power machine needs correctly rated enclosed contactors, fuses, earthing, and a qualified electrician.
6. Power and test safely
Power the ESP32 from its USB port using the regulated 5 V USB supply (power). Turn on the separate 12 V adapter for the coin acceptor. Before connecting a real machine, test each relay using its indicator light and test the buttons, display, coin pulses, buzzer, and Wi-Fi page.
- After the controller starts, connect a phone to Wi-Fi network Carwash-Vendo using password carwash123; the phone’s sign-in page should open the dashboard.
- After a power interruption, the saved time is shown as paused and every relay remains off; pressing its start button resumes that one machine.
- Use a fuse appropriate to each power supply and do not power the ESP32 from more than one 5 V source at the same time.
Review all connections
1. Connections between "lcd2004_1" and "ESP32"
2. Connections between "buzzer_1" and "ESP32"
3. Connections between "start_button_1" and "ESP32"
4. Connections between "start_button_2" and "ESP32"
5. Connections between "start_button_3" and "ESP32"
6. Connections between "start_button_4" and "ESP32"
7. Connections between "relay4_1" and "ESP32"
8. Connections between "supply12v_1" and "ESP32"
9. Connections between "coin_acceptor_1" and "ESP32"
10. Connections between "coin_opto_1" and "ESP32"
11. Connections between "supply5v_1" and "ESP32"
Deploy the firmware
#include <Arduino.h>
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <WiFi.h>
#include <WebServer.h>
#include <DNSServer.h>
#include <Preferences.h>
constexpr uint8_t LCD_SDA=21,LCD_SCL=22,BUZZER_PIN=26,COIN_PIN=27;
const uint8_t buttonPins[4]={18,19,23,25},relayPins[4]={13,14,16,17};
constexpr uint8_t RELAY_ON=LOW,RELAY_OFF=HIGH; constexpr int MINIMUM_CREDIT=5; constexpr uint32_t COIN_DEBOUNCE_MS=90;
LiquidCrystal_I2C lcd(0x27,20,4); WebServer server(80); DNSServer dns; Preferences prefs;
struct Machine { String name; uint16_t seconds,price; bool enabled; uint32_t sales; } machines[4];
int credits=0,activeMachine=-1; uint32_t coinsInserted=0,remainingSeconds=0,lastSecondMs=0,lastSaveMs=0,lastDisplayMs=0,lastCoinMs=0; bool previousButton[4]={HIGH,HIGH,HIGH,HIGH};
void beep(uint16_t ms=70){ledcWriteTone(BUZZER_PIN,2400);delay(ms);ledcWriteTone(BUZZER_PIN,0);} void errorBeep(){beep(70);delay(90);beep(70);} void setRelaysOff(){for(uint8_t i=0;i<4;i++)digitalWrite(relayPins[i],RELAY_OFF);}
String esc(String s){s.replace("&","&");s.replace("<","<");s.replace(">",">");s.replace("\"",""");return s;}
void saveState(){prefs.putInt("credit",credits);prefs.putUInt("coins",coinsInserted);prefs.putInt("active",activeMachine);prefs.putUInt("remain",remainingSeconds);for(uint8_t i=0;i<4;i++){String k=String(i);prefs.putString(("n"+k).c_str(),machines[i].name);prefs.putUShort(("t"+k).c_str(),machines[i].seconds);prefs.putUShort(("p"+k).c_str(),machines[i].price);prefs.putBool(("e"+k).c_str(),machines[i].enabled);prefs.putUInt(("s"+k).c_str(),machines[i].sales);}}
void loadState(){const char*d[4]={"WATER 1","WATER 2","BLOWER","SHAMPOO"};for(uint8_t i=0;i<4;i++){String k=String(i);machines[i].name=prefs.getString(("n"+k).c_str(),d[i]);machines[i].seconds=prefs.getUShort(("t"+k).c_str(),120);machines[i].price=prefs.getUShort(("p"+k).c_str(),5);machines[i].enabled=prefs.getBool(("e"+k).c_str(),true);machines[i].sales=prefs.getUInt(("s"+k).c_str(),0);}credits=prefs.getInt("credit",0);coinsInserted=prefs.getUInt("coins",0);activeMachine=prefs.getInt("active",-1);remainingSeconds=prefs.getUInt("remain",0);if(activeMachine<0||activeMachine>3||!remainingSeconds){activeMachine=-1;remainingSeconds=0;}}
void line(uint8_t r,String s){while(s.length()<20)s+=' ';lcd.setCursor(0,r);lcd.print(s.substring(0,20));}
void display(){if(millis()-lastDisplayMs<250)return;lastDisplayMs=millis();if(activeMachine>=0){uint32_t m=remainingSeconds/60,s=remainingSeconds%60;line(0,"RUN "+String(activeMachine+1)+" "+machines[activeMachine].name);line(1,"TIME "+String(m)+":"+(s<10?"0":"")+String(s));line(2,"CREDIT P"+String(credits)+" COINS "+String(coinsInserted));line(3,"Other machines LOCKED");}else{line(0,"CARWASH VENDO 4-IN-1");line(1,"CREDIT: P"+String(credits)+" COINS:"+String(coinsInserted));line(2,"Press 1-4 to start");line(3,"Minimum credit: P5");}}
void stopMachine(bool done){if(activeMachine<0)return;digitalWrite(relayPins[activeMachine],RELAY_OFF);if(done)beep(350);activeMachine=-1;remainingSeconds=0;saveState();}
void startMachine(uint8_t i){if(activeMachine>=0){errorBeep();return;}if(!machines[i].enabled||credits<(int)machines[i].price||credits<MINIMUM_CREDIT){errorBeep();return;}credits-=machines[i].price;machines[i].sales+=machines[i].price;activeMachine=i;remainingSeconds=machines[i].seconds;setRelaysOff();digitalWrite(relayPins[i],RELAY_ON);beep();saveState();}
void buttons(){for(uint8_t i=0;i<4;i++){bool n=digitalRead(buttonPins[i]);if(previousButton[i]&& !n){delay(20);if(!digitalRead(buttonPins[i]))startMachine(i);}previousButton[i]=n;}}
void coin(){static bool last=HIGH;bool n=digitalRead(COIN_PIN);if(last&&!n&&millis()-lastCoinMs>COIN_DEBOUNCE_MS){lastCoinMs=millis();credits++;coinsInserted++;beep(35);saveState();}last=n;}
String page(){String p="<!doctype html><html><head><meta name=viewport content='width=device-width,initial-scale=1'><title>Carwash Vendo</title><style>body{font-family:Arial;background:#eef3f7;color:#17212b;max-width:800px;margin:auto;padding:14px}h1{color:#075497}.c{background:#fff;border-radius:12px;padding:14px;margin:12px 0;box-shadow:0 2px 8px #bbc}input{width:75px;padding:7px;margin:2px}.n{width:120px}button{background:#075497;color:#fff;border:0;border-radius:7px;padding:10px;margin:3px;font-weight:bold}.d{background:#a22}table{width:100%;border-collapse:collapse}td,th{padding:6px;border-bottom:1px solid #ddd;text-align:left}</style></head><body><h1>CARWASH VENDO</h1><div class=c><b>Available credit: P"+String(credits)+" Coins inserted: "+String(coinsInserted)+"</b>";if(activeMachine>=0)p+="<p>RUNNING: <b>"+esc(machines[activeMachine].name)+"</b> — "+String(remainingSeconds/60)+":"+String(remainingSeconds%60)+" remaining. Other machines are locked.</p>";else p+="<p>No machine is running. Insert coins, then press its physical start button.</p>";p+="</div><form action=/save><div class=c><h2>Services, time and price</h2><table><tr><th>Service</th><th>Seconds</th><th>Price (P)</th><th>Available</th></tr>";for(uint8_t i=0;i<4;i++)p+="<tr><td><input class=n name=n"+String(i)+" value=\""+esc(machines[i].name)+"\"></td><td><input type=number min=1 name=t"+String(i)+" value="+String(machines[i].seconds)+"></td><td><input type=number min=5 name=p"+String(i)+" value="+String(machines[i].price)+"></td><td><input type=checkbox name=e"+String(i)+(machines[i].enabled?" checked":"")+"></td></tr>";p+="</table><button>Save services</button></div></form><div class=c><h2>Sales report</h2><table><tr><th>Service</th><th>Income</th></tr>";uint32_t total=0;for(uint8_t i=0;i<4;i++){total+=machines[i].sales;p+="<tr><td>"+esc(machines[i].name)+"</td><td>P"+String(machines[i].sales)+"</td></tr>";}p+="</table><p><b>Total income: P"+String(total)+"</b></p><a href=/clear><button class=d>Clear sales report</button></a></div><div class=c><h2>Wi-Fi configuration</h2><p>Connect to <b>Carwash-Vendo</b>, password <b>carwash123</b>. Your phone's sign-in page opens this dashboard automatically.</p><a href=/reboot><button>Restart controller</button></a></div></body></html>";return p;}
void web(){WiFi.mode(WIFI_AP);WiFi.softAP("Carwash-Vendo","carwash123");dns.start(53,"*",WiFi.softAPIP());server.on("/",[](){server.send(200,"text/html",page());});server.on("/save",[](){for(uint8_t i=0;i<4;i++){String x=String(i);if(server.hasArg("n"+x))machines[i].name=server.arg("n"+x);if(server.hasArg("t"+x))machines[i].seconds=constrain(server.arg("t"+x).toInt(),1,36000);if(server.hasArg("p"+x))machines[i].price=constrain(server.arg("p"+x).toInt(),5,10000);machines[i].enabled=server.hasArg("e"+x);}saveState();server.sendHeader("Location","/");server.send(303);});server.on("/clear",[](){for(auto&m:machines)m.sales=0;saveState();server.sendHeader("Location","/");server.send(303);});server.on("/reboot",[](){server.send(200,"text/plain","Restarting...");delay(250);ESP.restart();});server.onNotFound([](){server.sendHeader("Location",String("http://")+WiFi.softAPIP().toString()+"/");server.send(302,"text/plain","Opening dashboard");});server.begin();}
void setup(){for(uint8_t i=0;i<4;i++){pinMode(relayPins[i],OUTPUT);digitalWrite(relayPins[i],RELAY_OFF);pinMode(buttonPins[i],INPUT_PULLUP);}pinMode(COIN_PIN,INPUT_PULLUP);ledcAttach(BUZZER_PIN,2000,8);Wire.begin(LCD_SDA,LCD_SCL);lcd.init();lcd.backlight();prefs.begin("carwash",false);loadState();setRelaysOff();web();line(0,"CARWASH VENDO READY");line(1,"WiFi: Carwash-Vendo");line(2,"Password: carwash123");line(3,"Recovered time paused");delay(1300);}
void loop(){dns.processNextRequest();server.handleClient();buttons();coin();if(activeMachine>=0&&millis()-lastSecondMs>=1000){lastSecondMs+=1000;if(remainingSeconds)remainingSeconds--;if(remainingSeconds&&remainingSeconds<=5)beep(55);if(!remainingSeconds)stopMachine(true);}if(millis()-lastSaveMs>=1000){lastSaveMs=millis();if(activeMachine>=0)saveState();}display();}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.




