Community project

Active-Tracking Robot Vehicle

ESP32
Photo of Active-Tracking Robot Vehicle
Generated with AI

Hreyansh Nain

Published October 25, 2026

This active-tracking robot vehicle uses an ESP32 microcontroller to autonomously follow a target while displaying status on an onboard OLED screen. The four-wheel tank-drive platform is powered by DC gearmotors controlled through an L298N motor driver, with servo-controlled mechanisms for additional functionality.

The guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions for building and configuring the rover. Firmware is included to enable WiFi-based manual control, autonomous following modes, and a maintenance dashboard accessible via the onboard web server.

Wiring diagram

Wiring diagram for Active-Tracking Robot Vehicle

Gather all the parts

QtyComponent
1

SSD1306 OLED

0.96 inch 128x64 OLED display with I2C interface

1

Push Button

GPIO4 mode button

Momentary push button switch

1

Push Button

GPIO27 service-mode button

Momentary push button switch

1

SG90 Servo

Lower tray servo

Micro servo motor (SG90)

1

SG90 Servo

Upper service boot servo

Micro servo motor (SG90)

1

L298N dual H-bridge motor driver module

A two-channel motor driver module that powers the left and right tank-drive motor pairs.

1

Front-left DC gearmotor

One of the two left-side motors that turns the rover's left wheels.

1

Rear-left DC gearmotor

One of the two left-side motors that turns the rover's left wheels.

1

Front-right DC gearmotor

One of the two right-side motors that turns the rover's right wheels.

1

Rear-right DC gearmotor

One of the two right-side motors that turns the rover's right wheels.

Assemble it in 6 steps

1. Keep the electronics unpowered while rewiring

Unplug the USB cable and disconnect the motor battery before moving wires. This stops the rover from moving unexpectedly while the L298N, servos, and buttons are being changed.

  • Do not test the motors with the wheels lifted near fingers, loose wires, or the service tray.

2. Connect the L298N control wires

Connect ENA to GPIO14 (left-side motor-pair enable), IN1 to GPIO27 (left direction), IN2 to GPIO26 (left direction), ENB to GPIO25 (right-side motor-pair enable), IN3 to GPIO33 (right direction), and IN4 to GPIO32 (right direction). Connect the L298N GND to ESP32 GND (shared ground).

  • Remove the ENA and ENB jumper caps if your particular L298N board has them; these two pins must be connected to the ESP32 for the firmware to enable or stop each side.
  • Make sure the L298N ground and ESP32 ground are connected together — without this shared ground, the direction signals cannot be read reliably.

3. Make the two parallel tank-drive pairs

Connect L298N OUT1 to the M+ lead of left_front_motor and left_rear_motor, then connect OUT2 to the M- lead of both left motors. Connect OUT3 to the M+ lead of right_front_motor and right_rear_motor, then connect OUT4 to the M- lead of both right motors. This makes each L298N channel run two motors on one side of the rover.

  • If one wheel spins the opposite way from the other wheel on the same side, swap only that motor's two leads.
  • Four motors can draw more current than an L298N module can safely supply, especially when starting or stalled. Use a motor supply and driver rating suitable for the measured stall current of both motors on each channel.

4. Move the service controls

Connect the lower tray servo signal wire to GPIO16 (signal), the service-boot servo signal wire to GPIO17 (signal), and the Service Mode push button between GPIO13 (signal) and GND (ground). The firmware uses the ESP32's internal pull-up, so the button reads as pressed when it connects GPIO13 to GND.

  • Power both servos from a stable 5 V supply and connect that supply ground to ESP32 GND — servo current can reset the ESP32 if it is taken from the 3V3 pin.

5. Check the remaining control wiring

Keep the mode button between GPIO4 (signal) and GND (ground). Keep OLED VCC to 3V3 (power), OLED GND to GND (ground), OLED SDA to GPIO21 (data), and OLED SCL to GPIO22 (clock).

  • Make sure OLED VCC and GND are not swapped — swapped power can damage the screen.

6. Power up for the first test

Lift the rover so its wheels cannot touch the floor, power the motor system and ESP32, then use the control page. Push the Ghost Stick upward to drive both sides forward; drag it left or right to turn the rover in place or curve it.

  • If pushing up makes the rover reverse, swap the two motor leads for both motors on each side, or change the direction in firmware after confirming the wiring.

Review all connections

1. Connections between "oled_1" and "ESP32"

Functionoled_1ESP32
powerVCC → OLED power3V3
groundGND → OLED groundGND
i2cSDA → OLED dataGPIO 21
i2cSCL → OLED clockGPIO 22

2. Connections between "mode_button" and "ESP32"

Functionmode_buttonESP32
groundGND → Mode button groundGND
digitalSIGNAL → Mode button signal with internal pull-upGPIO 4

3. Connections between "service_button" and "ESP32"

Functionservice_buttonESP32
groundGND → Service button groundGND
digitalSIGNAL → Service Mode button signal with internal pull-upGPIO 13

4. Connections between "tray_servo" and "ESP32"

Functiontray_servoESP32
powerVCC → Lower tray servo power5V
groundGND → Lower tray servo groundGND
pwmSIGNAL → Lower tray servo controlGPIO 16

5. Connections between "boot_servo" and "ESP32"

Functionboot_servoESP32
powerVCC → Service boot servo power5V
groundGND → Service boot servo groundGND
pwmSIGNAL → Service boot servo controlGPIO 17

6. Connections between "l298n_driver" and "ESP32"

Functionl298n_driverESP32
power5V → L298N logic power5V
groundGND → Shared L298N groundGND
pwmENA → Left channel enable and speed controlGPIO 14
digitalIN1 → Left channel direction input 1GPIO 27
digitalIN2 → Left channel direction input 2GPIO 26
pwmENB → Right channel enable and speed controlGPIO 25
digitalIN3 → Right channel direction input 1GPIO 33
digitalIN4 → Right channel direction input 2GPIO 32
powerOUT1 → Front-left DC gearmotor M+EXT
powerOUT1 → Rear-left DC gearmotor M+EXT
powerOUT2 → Front-left DC gearmotor M-EXT
powerOUT2 → Rear-left DC gearmotor M-EXT
powerOUT3 → Front-right DC gearmotor M+EXT
powerOUT3 → Rear-right DC gearmotor M+EXT
powerOUT4 → Front-right DC gearmotor M-EXT
powerOUT4 → Rear-right DC gearmotor M-EXT

Deploy the firmware

#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <ESP32Servo.h>
#include <Preferences.h>
#include <math.h>

// Local-only rover firmware. The AP password protects the wireless network;
// HTTP Basic authentication protects the maintenance dashboard.
constexpr char SSID[]="Direct follow", PASS[]="12345678", ADMIN[]="admin", ADMIN_PASS[]="3245";
constexpr int OLED_SDA_PIN=21, OLED_SCL_PIN=22, MODE_BUTTON_PIN=4;
// L298N tank-drive channels: channel A is the left motor pair; channel B is the right motor pair.
constexpr int ENA_PIN=14, IN1_PIN=27, IN2_PIN=26, ENB_PIN=25, IN3_PIN=33, IN4_PIN=32;
// These moved away from the L298N pins.
constexpr int TRAY_SERVO_PIN=16, BOOT_SERVO_PIN=17, SERVICE_BUTTON_PIN=13;
constexpr uint32_t DEBOUNCE=35, DOUBLE_CLICK=350, STICK_TIMEOUT=500, GPS_TIMEOUT=5000;
constexpr float STOP_DISTANCE=2.0f, HEADING_TOLERANCE=14.0f;
Adafruit_SSD1306 oled(128,64,&Wire,-1); AsyncWebServer server(80); Servo tray,boot; Preferences prefs;
enum State:uint8_t{WAITING,MANUAL,FOLLOW_READY,COUNTDOWN,FOLLOWING,SERVICE}; State state=WAITING;
bool ai=false,connected=false,tabletOK=false,targetOK=false,trayOpen=false,bootOpen=false;
float tLat,tLng,tHead,gLat,gLng; uint32_t started,lastStick,lastPhone,lastTablet,lastTarget,lastFollow;
bool raw4=HIGH,stable4=HIGH,raw27=HIGH,stable27=HIGH,pending=false; uint32_t raw4At,raw27At,firstClick,holdAt;
struct Room{String name;float lat,lng;bool valid;}; Room rooms[8]; String message="Robo-helper ready.";

const char GPS[] PROGMEM=R"X(<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{background:#101820;color:white;font:20px Arial;text-align:center;padding:24px}button{font-size:22px;padding:18px;background:#287ee8;color:white;border:0;border-radius:12px}.eyes{font-size:70px;color:#29aaff}</style><div id=e class=eyes style="display:none">● ●</div><p>Welcome to the ESP32 robot car. This device is used as a GPS and a pass for the robot. Please mount it on the robot.</p><button id=b>I mounted it on the bottom</button><p id=s></p><script>let lat,lng,head=null;function send(){if(lat!==undefined&&head!==null)fetch('/update-data?lat='+lat+'&lng='+lng+'&heading='+head).catch(()=>{})}function bad(){s.textContent='Browser not supported.'}async function go(){if(!navigator.geolocation||!window.DeviceOrientationEvent)return bad();try{if(DeviceOrientationEvent.requestPermission&&await DeviceOrientationEvent.requestPermission()!='granted')return bad();addEventListener('deviceorientationabsolute',o=>{head=o.webkitCompassHeading??o.alpha;if(typeof head=='number')send()},true);addEventListener('deviceorientation',o=>{head=o.webkitCompassHeading??o.alpha;if(typeof head=='number')send()},true);navigator.geolocation.watchPosition(p=>{lat=p.coords.latitude;lng=p.coords.longitude;send()},bad,{enableHighAccuracy:true});s.textContent='Tracking location and heading.';b.style.display='none'}catch(x){bad()}}b.onclick=go;fetch('/ai-status').then(x=>x.json()).then(x=>{if(!x.ai)e.style.display='block'})</script>)X";
const char CONTROL[] PROGMEM=R"X(<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no"><style>body{margin:0;background:#101820;color:white;font:Arial;overflow:hidden}.bar{position:absolute;padding:10px;z-index:2}.bar button{font-size:16px;padding:11px;border:0;border-radius:9px;color:white;background:#2677d8}.bar .stop{background:#d22835}.area{height:100vh;touch-action:none}.r,.k{position:absolute;display:none;transform:translate(-50%,-50%);border-radius:50%}.r{height:120px;width:120px;border:3px solid #6cbcff}.k{height:52px;width:52px;background:#6cbcff}</style><div class=bar><button class=stop onclick="fetch('/estop')">EMERGENCY STOP</button><button onclick="fetch('/switch-follow')">Switch Follow</button><button onclick="come()">Come to me</button></div><div id=a class=area><i id=r class=r></i><i id=k class=k></i></div><script>let ox,oy,on=false;function tx(x,y){fetch('/joystick?x='+x+'&y='+y).catch(()=>{})}function mv(t){let x=t.clientX-ox,y=t.clientY-oy,d=Math.hypot(x,y);if(d>60){x*=60/d;y*=60/d}k.style.left=ox+x+'px';k.style.top=oy+y+'px';tx(x/60,-y/60)}a.ontouchstart=x=>{let t=x.touches[0];on=true;ox=t.clientX;oy=t.clientY;r.style.left=ox+'px';r.style.top=oy+'px';r.style.display=k.style.display='block';mv(t);x.preventDefault()};a.ontouchmove=x=>{if(on)mv(x.touches[0]);x.preventDefault()};function end(){on=false;r.style.display=k.style.display='none';tx(0,0)}a.ontouchend=end;a.ontouchcancel=end;fetch('/control-connected');setInterval(()=>fetch('/control-connected'),1000);function come(){navigator.geolocation?navigator.geolocation.getCurrentPosition(p=>fetch('/target-data?lat='+p.coords.latitude+'&lng='+p.coords.longitude).then(()=>fetch('/switch-follow')),()=>alert('Browser not supported.'),{enableHighAccuracy:true}):alert('Browser not supported.')}</script>)X";
const char SERVICE_PAGE[] PROGMEM=R"X(<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{background:#111;color:#eee;font:18px Arial;padding:18px}button,input{font-size:18px;padding:11px;margin:5px}button{background:#286dcc;color:white;border:0;border-radius:8px}</style><h2>Service Control Dashboard</h2><p id=s>Loading...</p><button onclick="f('/service/open-boot')">Open Service Boot</button><button onclick="f('/service/return')">Return to Starting Position</button><button onclick="f('/service/tray')">Toggle Public Tray</button><hr><h3>Robo-helper/bot</h3><button onclick="f('/ai/toggle')">Toggle Gemini AI mode</button><p>Local commands: “go to kitchen” or “list rooms”.</p><input id=c placeholder="go to kitchen"><button onclick="fetch('/bot?text='+encodeURIComponent(c.value)).then(x=>x.json()).then(x=>alert(x.reply))">Send</button><p><a href="/robo-helper/new-homes">Add a named room</a></p><script>function f(u){fetch(u).then(refresh)}function refresh(){fetch('/status').then(x=>x.json()).then(x=>s.textContent='Mode: '+x.mode+' | AI: '+(x.ai?'on':'off')+' | '+x.message)}refresh();setInterval(refresh,1000)</script>)X";
const char ROOMS[] PROGMEM=R"X(<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{background:#111;color:#eee;font:18px Arial;padding:18px}input,button{font-size:18px;padding:10px}</style><h2>Add a named room</h2><p>Stand at the room reference point, name it, then save.</p><input id=n placeholder="Kitchen"><button onclick="save()">Save this location</button><p id=o></p><script>function save(){if(!navigator.geolocation){o.textContent='Browser not supported.';return}navigator.geolocation.getCurrentPosition(p=>fetch('/room/save?name='+encodeURIComponent(n.value)+'&lat='+p.coords.latitude+'&lng='+p.coords.longitude).then(x=>x.json()).then(x=>o.textContent=x.reply),()=>o.textContent='Browser not supported.',{enableHighAccuracy:true})}</script>)X";

// Disable both L298N channels before changing direction, then drive each tank side.
void stopAll(){
  digitalWrite(ENA_PIN,LOW); digitalWrite(ENB_PIN,LOW);
  digitalWrite(IN1_PIN,LOW); digitalWrite(IN2_PIN,LOW);
  digitalWrite(IN3_PIN,LOW); digitalWrite(IN4_PIN,LOW);
}
void setTankChannel(int enablePin,int forwardPin,int reversePin,float value){
  if(fabsf(value)<0.12f){digitalWrite(enablePin,LOW);digitalWrite(forwardPin,LOW);digitalWrite(reversePin,LOW);return;}
  digitalWrite(forwardPin,value>0.0f?HIGH:LOW);
  digitalWrite(reversePin,value>0.0f?LOW:HIGH);
  digitalWrite(enablePin,HIGH);
}
void tankDrive(float left,float right){
  if(state==SERVICE){stopAll();return;}
  left=constrain(left,-1.0f,1.0f); right=constrain(right,-1.0f,1.0f);
  setTankChannel(ENA_PIN,IN1_PIN,IN2_PIN,left);
  setTankChannel(ENB_PIN,IN3_PIN,IN4_PIN,right);
}
String nameOf(){const char*n[]={"waiting","manual","follow ready","follow countdown","direct follow active","service mode"};return n[state];}
void screen(){oled.clearDisplay();oled.setTextSize(1);oled.setTextColor(SSD1306_WHITE);if(state==WAITING)oled.println("Robot vehicle waiting");else if(state==MANUAL){oled.println("Manual control");oled.println();oled.println(connected?"connected":"Not connected");}else if(state==FOLLOW_READY)oled.println("Follow: press and\nhold button to start.");else if(state==COUNTDOWN){oled.println("Follow starting");oled.print("COUNT: ");oled.println(min(3UL,(millis()-holdAt)/1000UL+1));}else if(state==FOLLOWING){oled.println("Direct follow active");oled.println();oled.println("AP: Direct follow");}else{oled.println("SERVICE MODE");oled.println();oled.println("Driving locked");}oled.display();}
bool auth(AsyncWebServerRequest*r){if(!r->authenticate(ADMIN,ADMIN_PASS)){r->requestAuthentication();return false;}return true;}
void json(AsyncWebServerRequest*r,String s){r->send(200,"application/json",s);}
float rad(float x){return x*PI/180.0f;}float distanceM(){float a=rad(tLat),b=rad(gLat),d=rad(gLat-tLat),e=rad(gLng-tLng);float x=sinf(d/2)*sinf(d/2)+cosf(a)*cosf(b)*sinf(e/2)*sinf(e/2);return 6371000*2*atan2f(sqrtf(x),sqrtf(1-x));}float bearing(){float d=rad(gLng-tLng),y=sinf(d)*cosf(rad(gLat)),x=cosf(rad(tLat))*sinf(rad(gLat))-sinf(rad(tLat))*cosf(rad(gLat))*cosf(d);return fmodf(atan2f(y,x)*180/PI+360,360);}
void follow(){if(!tabletOK||!targetOK||millis()-lastTablet>GPS_TIMEOUT||millis()-lastTarget>GPS_TIMEOUT){stopAll();message="Follow paused: waiting for fresh location.";return;}if(distanceM()<STOP_DISTANCE){stopAll();message="At target location.";return;}float e=fmodf(bearing()-tHead+540,360)-180;if(fabsf(e)>HEADING_TOLERANCE){
    // Turn in place until the tablet compass faces the target.
    tankDrive(e<0.0f?-0.70f:0.70f,e<0.0f?0.70f:-0.70f);
  }else{
    tankDrive(0.80f,0.80f);
  }}
String bot(String c){c.trim();c.toLowerCase();if(!ai)return "Robo-helper is off. Turn it on from the service dashboard.";if(c=="list rooms"){String out="Saved rooms: ";bool any=false;for(auto &q:rooms)if(q.valid){out+=(any?", ":"")+q.name;any=true;}return any?out:"No rooms have been saved yet.";}if(c.startsWith("go to ")){String w=c.substring(6);w.trim();for(auto&q:rooms)if(q.valid&&q.name.equalsIgnoreCase(w)){gLat=q.lat;gLng=q.lng;targetOK=true;lastTarget=millis();stopAll();state=FOLLOWING;message="Going to "+q.name+".";screen();return message;}return "Sorry, I could not do your request. I'm really sorry, but there is no home name like this.";}return "Try: go to kitchen, or list rooms.";}
void routes(){server.on("/",HTTP_GET,[](AsyncWebServerRequest*r){r->redirect("/control-phone");});server.on("/GPS",HTTP_GET,[](AsyncWebServerRequest*r){r->send_P(200,"text/html",GPS);});server.on("/control-phone",HTTP_GET,[](AsyncWebServerRequest*r){r->send_P(200,"text/html",CONTROL);});server.on("/robo-helper-secret",HTTP_GET,[](AsyncWebServerRequest*r){if(auth(r))r->send_P(200,"text/html",SERVICE_PAGE);});server.on("/robo-helper/new-homes",HTTP_GET,[](AsyncWebServerRequest*r){if(auth(r))r->send_P(200,"text/html",ROOMS);});server.on("/control-connected",HTTP_GET,[](AsyncWebServerRequest*r){connected=true;lastPhone=millis();r->send(204);});server.on("/update-data",HTTP_GET,[](AsyncWebServerRequest*r){if(r->hasParam("lat")&&r->hasParam("lng")&&r->hasParam("heading")){tLat=r->getParam("lat")->value().toFloat();tLng=r->getParam("lng")->value().toFloat();tHead=r->getParam("heading")->value().toFloat();tabletOK=true;lastTablet=millis();r->send(204);}else r->send(400,"text/plain","lat, lng, and heading are required");});server.on("/target-data",HTTP_GET,[](AsyncWebServerRequest*r){if(r->hasParam("lat")&&r->hasParam("lng")){gLat=r->getParam("lat")->value().toFloat();gLng=r->getParam("lng")->value().toFloat();targetOK=true;lastTarget=millis();r->send(204);}else r->send(400,"text/plain","lat and lng are required");});server.on("/joystick",HTTP_GET,[](AsyncWebServerRequest*r){float x=r->hasParam("x")?r->getParam("x")->value().toFloat():0,y=r->hasParam("y")?r->getParam("y")->value().toFloat():0;lastStick=millis();
  if(state==MANUAL){
    // Differential mixing: forward/back controls both sides; horizontal motion changes their relative directions.
    tankDrive(y+x,y-x);
  }else stopAll();
  r->send(204);});server.on("/estop",HTTP_GET,[](AsyncWebServerRequest*r){stopAll();state=MANUAL;message="Emergency stop applied.";screen();r->send(204);});server.on("/switch-follow",HTTP_GET,[](AsyncWebServerRequest*r){if(state!=SERVICE){stopAll();state=FOLLOW_READY;message="Follow ready: hold the physical button.";screen();}r->send(204);});server.on("/service/open-boot",HTTP_GET,[](AsyncWebServerRequest*r){if(!auth(r))return;stopAll();boot.write(100);bootOpen=true;message="Service boot opened.";r->send(204);});server.on("/service/return",HTTP_GET,[](AsyncWebServerRequest*r){if(!auth(r))return;tray.write(0);boot.write(0);trayOpen=bootOpen=false;stopAll();if(state==SERVICE)state=MANUAL;message="Both servos closed; manual mode restored.";screen();r->send(204);});server.on("/service/tray",HTTP_GET,[](AsyncWebServerRequest*r){if(!auth(r))return;if(state==SERVICE){r->send(423,"text/plain","Tray locked in service mode");return;}trayOpen=!trayOpen;tray.write(trayOpen?90:0);r->send(204);});server.on("/ai/toggle",HTTP_GET,[](AsyncWebServerRequest*r){if(!auth(r))return;ai=!ai;prefs.putBool("ai",ai);message=ai?"Robo-helper command mode on.":"Robo-helper command mode off; tablet eyes shown.";r->send(204);});server.on("/ai-status",HTTP_GET,[](AsyncWebServerRequest*r){json(r,String("{\"ai\":")+(ai?"true":"false")+"}");});server.on("/status",HTTP_GET,[](AsyncWebServerRequest*r){String m=message;m.replace("\"","'");json(r,"{\"mode\":\""+nameOf()+"\",\"ai\":"+(ai?"true":"false")+",\"message\":\""+m+"\"}");});server.on("/room/save",HTTP_GET,[](AsyncWebServerRequest*r){if(!auth(r))return;if(!r->hasParam("name")||!r->hasParam("lat")||!r->hasParam("lng")){r->send(400,"application/json","{\"reply\":\"Name and location are required.\"}");return;}String n=r->getParam("name")->value();n.trim();if(n.length()<1||n.length()>20){r->send(400,"application/json","{\"reply\":\"Use a room name of 1 to 20 characters.\"}");return;}int i=0;for(;i<8;i++)if(!rooms[i].valid||rooms[i].name.equalsIgnoreCase(n))break;if(i==8)i=0;rooms[i]={n,r->getParam("lat")->value().toFloat(),r->getParam("lng")->value().toFloat(),true};prefs.putString(("n"+String(i)).c_str(),n);prefs.putFloat(("a"+String(i)).c_str(),rooms[i].lat);prefs.putFloat(("o"+String(i)).c_str(),rooms[i].lng);json(r,"{\"reply\":\"Saved "+n+".\"}");});server.on("/bot",HTTP_GET,[](AsyncWebServerRequest*r){if(!auth(r))return;String x=bot(r->hasParam("text")?r->getParam("text")->value():"");x.replace("\"","'");json(r,"{\"reply\":\""+x+"\"}");});server.onNotFound([](AsyncWebServerRequest*r){r->send(404,"text/plain","Not found");});}
void setup(){Serial.begin(115200);pinMode(MODE_BUTTON_PIN,INPUT_PULLUP);pinMode(SERVICE_BUTTON_PIN,INPUT_PULLUP);for(int p:{ENA_PIN,IN1_PIN,IN2_PIN,ENB_PIN,IN3_PIN,IN4_PIN})pinMode(p,OUTPUT);stopAll();Wire.begin(OLED_SDA_PIN,OLED_SCL_PIN);oled.begin(SSD1306_SWITCHCAPVCC,0x3C);tray.setPeriodHertz(50);boot.setPeriodHertz(50);tray.attach(TRAY_SERVO_PIN,500,2400);boot.attach(BOOT_SERVO_PIN,500,2400);tray.write(0);boot.write(0);prefs.begin("rover",false);ai=prefs.getBool("ai",false);for(int i=0;i<8;i++){rooms[i].name=prefs.getString(("n"+String(i)).c_str(),"");rooms[i].valid=rooms[i].name.length();rooms[i].lat=prefs.getFloat(("a"+String(i)).c_str(),0);rooms[i].lng=prefs.getFloat(("o"+String(i)).c_str(),0);}WiFi.mode(WIFI_AP);WiFi.softAP(SSID,PASS);routes();server.begin();started=millis();screen();}
void buttons(){uint32_t n=millis();bool a=digitalRead(MODE_BUTTON_PIN),b=digitalRead(SERVICE_BUTTON_PIN);if(a!=raw4){raw4=a;raw4At=n;}if(b!=raw27){raw27=b;raw27At=n;}if(a!=stable4&&n-raw4At>=DEBOUNCE){stable4=a;if(a==LOW&&state!=SERVICE){if(pending&&n-firstClick<=DOUBLE_CLICK){pending=false;stopAll();state=FOLLOW_READY;message="Follow ready: hold button to start.";screen();}else{pending=true;firstClick=n;}}}if(pending&&n-firstClick>DOUBLE_CLICK){pending=false;if(state==FOLLOW_READY&&stable4==LOW){state=COUNTDOWN;holdAt=n;screen();}}if(b!=stable27&&n-raw27At>=DEBOUNCE){stable27=b;if(b==LOW){stopAll();if(state==SERVICE){state=MANUAL;message="Service mode released.";}else{state=SERVICE;message="Service mode: driving and tray locked.";}screen();}}}
void loop(){uint32_t n=millis();buttons();if(state==WAITING&&n-started>=2000){state=MANUAL;screen();}if(connected&&n-lastPhone>3000){connected=false;if(state==MANUAL){stopAll();screen();}}if(state==MANUAL&&n-lastStick>STICK_TIMEOUT)stopAll();if(state==COUNTDOWN){if(stable4==HIGH){state=MANUAL;message="Follow countdown cancelled.";screen();}else if(n-holdAt>=3000){state=FOLLOWING;message="Direct follow active.";screen();}else screen();}if(state==FOLLOWING&&n-lastFollow>=250){lastFollow=n;follow();}delay(5);}

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