Zade: an Extensible WiFi Micro-Rover Platform With Web-UI and Python API, Powered by a Pocket Power Bank

by remandey in Circuits > Microcontrollers

93 Views, 0 Favorites, 0 Comments

Zade: an Extensible WiFi Micro-Rover Platform With Web-UI and Python API, Powered by a Pocket Power Bank

WhatsApp Image 2026-07-05 at 16.42.23.jpeg
rover_without_electronics_isometric.png
WhatsApp Image 2026-07-05 at 16.41.43.jpeg
WhatsApp Image 2026-07-05 at 16.42.42.jpeg

What if you could drive a robot from your phone or laptop — without any dedicated controller, radio transmitter, or bulky battery pack? That's exactly what the Micro-Rover (nicknamed Zade) achieves. Powered by nothing more than a standard 5V USB power bank, this compact WiFi-controlled rover runs entirely on battery and can be steered from any web browser on your phone or laptop.


At its heart is an ESP8266 microcontroller — a tiny chip that hosts a full web server and creates its own WiFi hotspot. The rover features a beautiful web dashboard with a virtual joystick, real-time trajectory tracking, a "Draw Mode" where you sketch a path and the rover follows it, and even tilt-to-steer using your phone's accelerometer. All of this runs off a pocket-sized power bank you probably already own.


This project is perfect for the Battery-Powered Contest because it demonstrates how far you can push tiny, efficient hardware: no wall power, no heavy lead-acid cells, just a modern lithium USB power bank keeping an intelligent robot alive and fully operational - and totally inexpensive!!!


Why this Micro-Rover stands out? Lets have a look at the cool features it has!!!

  1. Truly wireless — any browser = remote control, no app installation needed
  2. Battery-only — runs off any standard 5V USB power bank
  3. Draw Mode — sketch a path and watch the rover trace it
  4. Tilt Control — steer by tilting your smartphone
  5. Real-time trajectory canvas — see where the rover has been
  6. Dual WiFi modes — standalone hotspot or home network integration
  7. mDNS Support — Access the rover via "http://micro-rover.local", without requiring to note ip addresses.
  8. Python API for programmers — send commands from a PC or Raspberry Pi

Downloads

Supplies

mechanical.png
electronics.png

Hardware Components

  1. 2 wheel drive circular metal chassis(top+bottom)
  2. Screws
  3. Castor wheels

Electronic Components

  1. ESP-8266 Microcontroller
  2. L293N Motor Driver
  3. USB Cable
  4. BO Motors with wheels

Assembling the Chassis

rover_without_electronics_isometric.png
electronics.png
bottom_view.png

Tools & Fasteners Needed

  1. M3 Screws (typically 6mm for boards, 10mm–12mm for brackets)
  2. M3 Standoffs / Spacers (for layering the electronics plate)
  3. M3 Nuts
  4. Small Phillips screwdriver
  5. Double-sided mounting tape or zip-ties

Step-by-Step Assembly Workflow

1. Prepare the Base Chassis Plate

The bottom plate acts as the main structural foundation for the drivetrain.

  1. Identify the front and rear of your chassis plate.
  2. Ensure all pre-drilled holes or 3D-printed mounting points for the motor brackets and caster wheel are clear of debris.

2. Mount the DC Motors and Wheels

  1. Attach the Brackets: Secure the physical mounting brackets for the two DC motors onto the left and right sides of the lower chassis plate using M3 screws and nuts.
  2. Align and Secure Motors: Slide the DC motors into their respective brackets. Make sure the motor output shafts face outward and are perfectly perpendicular to the chassis frame line. Tighten the bracket screws securely so the motors do not twist under high torque.
  3. Press-Fit the Wheels: Push the main rubber wheels firmly onto the D-shaped output shafts of the DC motors until they click or sit flush against the shaft shoulder. Ensure there is a 1–2mm gap between the wheel hub and the chassis body so they turn completely unobstructed.

3. Install the Front/Rear Caster Wheel

Differential drive robots rely on a third, non-driven point of contact for stability.

  1. Position the omnidirectional caster wheel at the front center (or rear center, depending on your preferred orientation) of the bottom plate.
  2. Fix it in place using short M3 screws. If your caster wheel sits too low and tilts the robot, use small plastic washers or spacers to align its height with the main drive wheels so the chassis sits perfectly level.

4. Install the Electronics Standoffs & Upper Plate

To isolate the delicate microcontroller and wiring from the vibration and weight of the batteries, the rover uses a two-layer design.

  1. Thread four threaded M3 standoffs into the designated holes on the top side of the base chassis plate.
  2. Align the upper electronics plate with these standoffs and secure it in place using M3 screws. This elevated platform creates a safe, isolated space underneath for your battery packs while leaving the top clear for your circuitry.

5. Component Layout and Mounting

  1. Mount the Microcontroller (NodeMCU): Place the ESP8266 development board near the center or rear of the top electronics plate. Fasten it down using small screws through its corner mounting holes (or use high-quality double-sided foam tape if your board lacks mounting holes).
  2. Mount the Motor Driver (L298N): Fix the H-bridge driver module close to the center of the board, keeping its screw terminals easily accessible. Positioning it relatively close to the motors minimizes the length of heavy-gauge wire needed to connect them.

6. Battery Isolation and Routing

  1. Place your heavy motor battery pack on the lower deck (underneath the electronics plate) to lower the rover's center of gravity and prevent tipping during quick turns.
  2. Secure it firmly using a Velcro strap or zip-ties.
  3. Route the power cables and the motor wires upwards through the chassis cutouts/slots, ensuring they are kept completely away from the moving wheels and the caster pivot assembly.

System Architecture & Understanding the Kinematics

Before wiring, it is vital to understand how the rover moves. Zade uses a differential drive kinematics model. It doesn't have a steering wheel; instead, it turns by varying the speed of its left and right wheels independently.


When you send a linear velocity (v) and an angular steering velocity (ω), the movement resolves mathematically to:

VL = clamp(v + ω, -1, 1)

VR = clamp(v - ω, -1, 1)

On the ESP8266, these values are mapped to 10-bit PWM signals ranging from -1023 to 1023. Positive numbers spin the motor forward; negative numbers spin it backward.

Downloads

Making the Electronics Unit

electronics_checking.jpg
fixing_wires.jpg
fixing_wires2.jpg
power_cable.jpg
top_view_after_electronics.jpg
fixing_mcu.jpg
electronics_placement.png
electronics_plate.jpg
electronics_and_mechanical.jpeg
top_view.png

To keep your microcontroller from resetting due to voltage sags when the motors spark up, we must physically separate the logic power from the motor power.

Connect your components according to the table below. Make sure to link all Ground (GND) lines together to form a common reference potential!


ESP8266 GPIO Pin Motor Driver Pin Signal Type Functional Description

-----------------------------------------------------------------------------------------
| ESP8266 GPIO Pin | Motor Driver Pin | Signal Type | Functional Description |
-----------------------------------------------------------------------------------------
| D1 (GPIO5) | IN1 | Digital Output | Left Motor Direction A |
| D2 (GPIO4) | IN2 | Digital Output | Left Motor Direction B |
| D6 (GPIO12) | IN3 | Digital Output | Right Motor Direction A |
| D7 (GPIO13) | IN4 | Digital Output | Right Motor Direction B |
| D0 (GPIO16) | ENA | PWM Output | Left Motor Speed (10-bit) |
| D5 (GPIO14) | ENB | PWM Output | Right Motor Speed (10-bit) |
| GND | GND | Ground | Common Ground Reference |
-----------------------------------------------------------------------------------------


⚠️ CRITICAL FAIL-SAFE NOTE: > * Connect your 5V USB power bank only to the ESP8266's micro-USB port.
  1. Connect your separate battery pack only to the L298N motor driver's power terminals.
  2. Do not connect the motor driver’s high voltage output back into the ESP8266's 5V pin!

Step 3: Flash the ESP8266 Firmware

The C++ code below handles three critical jobs:

  1. It spins up a standalone secure Wi-Fi Network (micro_rover).
  2. It configures an mDNS responder so you can type http://micro-rover.local into a browser instead of memorizing an IP address.

It serves a lightweight REST API endpoint (/move?left=X&right=Y) with an integrated safety timeout switch.

Create a new sketch in the Arduino IDE named main.ino, install your ESP8266 board core, and flash this code:

#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <ESP8266mDNS.h>
// Made By Reman Dey
// --- Configuration ---
const char* WIFI_AP_SSID = "micro_rover";
const char* WIFI_AP_PASS = "12345678";
const char* WIFI_STA_SSID = "YOUR_WIFI_ID";
const char* WIFI_STA_PASS = "YOUR_WIFI_PASSWORD";
const char* MDNS_NAME = "micro-rover";

// --- Hardware Pins ---
const int PIN_IN1 = D1;
const int PIN_IN2 = D2;
const int PIN_IN3 = D6;
const int PIN_IN4 = D7;
const int PIN_ENA = D0;
const int PIN_ENB = D5;

const int MAX_PWM = 1023;

ESP8266WebServer server(80);
extern const char INDEX_HTML[] PROGMEM;

// --- Arduino Lifecycle ---

void setup() {
Serial.begin(115200);

// Pin Initialization
pinMode(LED_BUILTIN, OUTPUT);
pinMode(PIN_IN1, OUTPUT); pinMode(PIN_IN2, OUTPUT);
pinMode(PIN_IN3, OUTPUT); pinMode(PIN_IN4, OUTPUT);
pinMode(PIN_ENA, OUTPUT); pinMode(PIN_ENB, OUTPUT);

stopMotors();

// WiFi Setup
WiFi.mode(WIFI_AP);
WiFi.softAP(WIFI_AP_SSID, WIFI_AP_PASS);

Serial.printf("\nAccess Point Started! AP IP: %s\n",
WiFi.softAPIP().toString().c_str());

if (MDNS.begin(MDNS_NAME)) {
Serial.printf("mDNS: http://%s.local\n", MDNS_NAME);
}

server.on("/", handleRoot);
server.on("/move", handleMove);
server.begin();

Serial.println("Web Server Started");
}

void loop() {
server.handleClient();
MDNS.update();
}

// --- Motor Control Helpers ---

void setMotor(int in1, int in2, int enablePin, int speedValue) {
speedValue = constrain(speedValue, -MAX_PWM, MAX_PWM);

if (speedValue > 0) {
digitalWrite(in1, HIGH);
digitalWrite(in2, LOW);
analogWrite(enablePin, speedValue);
} else if (speedValue < 0) {
digitalWrite(in1, LOW);
digitalWrite(in2, HIGH);
analogWrite(enablePin, -speedValue);
} else {
digitalWrite(in1, LOW);
digitalWrite(in2, LOW);
analogWrite(enablePin, 0);
}
}

void moveRover(int leftSpeed, int rightSpeed) {
setMotor(PIN_IN1, PIN_IN2, PIN_ENA, leftSpeed);
setMotor(PIN_IN3, PIN_IN4, PIN_ENB, rightSpeed);
}

void stopMotors() {
moveRover(0, 0);
}

// --- Web Server Handlers ---

void handleRoot() {
server.send_P(200, "text/html", INDEX_HTML);
}

void handleMove() {
if (server.hasArg("left") && server.hasArg("right")) {
moveRover(server.arg("left").toInt(), server.arg("right").toInt());
server.send(200, "text/plain", "OK");
} else {
server.send(400, "text/plain", "Bad Request");
}
}

// --- HTML Content ---

const char INDEX_HTML[] PROGMEM = R"=====(
<!DOCTYPE html>
<html>
<head>
<title>Zade - Differential Drive Rover</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { margin:0; background:#111; color:white; font-family:Arial; text-align:center; }
h1 { margin-top:20px; }
#joystickContainer { width:300px; height:300px; background:#222; border-radius:50%; margin:40px auto; position:relative; touch-action:none; }
#joystick { width:100px; height:100px; background:#3498db; border-radius:50%; position:absolute; left:100px; top:100px; }
#trajectoryCanvas { background: #000; border: 1px solid #444; margin-top: 20px; border-radius: 8px; max-width: 90vw; }
.info { font-size:20px; margin-top:20px; }
button { background: #3498db; color: white; border: none; padding: 10px 20px; font-size: 16px; border-radius: 5px; cursor: pointer; margin: 10px; transition: background 0.3s; }
button:hover { background: #2980b9; }
button.active { background: #ff4757; }
</style>
</head>
<body>
<h1>Control Zade</h1>
<div id="joystickContainer"><div id="joystick"></div></div>
<div class="info">Drag joystick to move rover</div>
<canvas id="trajectoryCanvas" width="400" height="400"></canvas>
<br>
<button onclick="resetTrajectory()">Reset Trajectory</button>
<button id="drawBtn" onclick="toggleDrawMode()">Draw Mode: OFF</button>
<button id="tiltBtn" onclick="toggleTiltControl()">Tilt Control: OFF</button>
<button onclick="followDrawnPath()">Follow Path</button>
<script>
const joy = document.getElementById("joystick");
const container = document.getElementById("joystickContainer");
const canvas = document.getElementById("trajectoryCanvas");
const ctx = canvas.getContext("2d");

let dragging = false;
let drawMode = false;
let tiltEnabled = false;
let drawnPath = []; // Stores {forward, turn} commands
let drawnPathCoordinates = []; // Stores {x, y} for visual representation of the drawn path
let simulatedDrawX = 0; // Temporary for drawing simulation
let simulatedDrawY = 0; // Temporary for drawing simulation
let simulatedDrawHeading = 0; // Temporary for drawing simulation
let isFollowing = false;
let targetPointIndex = 0;
const centerX = 150, centerY = 150, maxRadius = 100;
const speedScale = 0.0015; // Global constant for movement simulation
const turnScale = 0.000006; // Global constant for movement simulation
let curL = 0, curR = 0;
let roverX = canvas.width / 2;
let roverY = canvas.height / 2;
let roverHeading = -Math.PI / 2;
let path = [{x: roverX, y: roverY}];

function sendMotor(left, right) {
console.log(`Command sent: L=${left}, R=${right}`);
fetch(`/move?left=${left}&right=${right}`)
.catch(err => {
console.error("Communication error with rover:", err);
});
}

function moveJoystick(x, y) { joy.style.left = (x - 50) + "px"; joy.style.top = (y - 50) + "px"; }

function resetTrajectory() {
roverX = canvas.width / 2;
roverY = canvas.height / 2;
roverHeading = -Math.PI / 2;
path = [{x: roverX, y: roverY}];
}

function toggleDrawMode() {
drawMode = !drawMode;
document.getElementById('drawBtn').innerText = `Draw Mode: ${drawMode ? 'ON' : 'OFF'}`;
document.getElementById('drawBtn').classList.toggle('active', drawMode);
if(drawMode) {
stop(); // Ensure real motors stop before drawing starts
drawnPath = [];
drawnPathCoordinates = [{x: roverX, y: roverY}]; // Start drawing from current rover position
simulatedDrawX = roverX; simulatedDrawY = roverY; simulatedDrawHeading = roverHeading;
}
}

function toggleTiltControl() {
if (!tiltEnabled && typeof DeviceOrientationEvent.requestPermission === 'function') {
DeviceOrientationEvent.requestPermission().then(response => {
if (response == 'granted') { tiltEnabled = true; startTilt(); }
});
} else {
tiltEnabled = !tiltEnabled;
if(tiltEnabled) startTilt();
}
document.getElementById('tiltBtn').innerText = `Tilt Control: ${tiltEnabled ? 'ON' : 'OFF'}`;
document.getElementById('tiltBtn').classList.toggle('active', tiltEnabled);
}

function startTilt() {
window.addEventListener('deviceorientation', (e) => {
if(!tiltEnabled || dragging) return;
// Beta: Front-to-back tilt (-90 to 90), Gamma: Left-to-right (-90 to 90)
let forward = Math.max(-1, Math.min(1, e.beta / 45));
let turn = Math.max(-1, Math.min(1, e.gamma / 45));

updateMotorValues(forward, turn);
moveJoystick(centerX + turn * maxRadius, centerY - forward * maxRadius);
});
}

function followDrawnPath() {
if (drawnPath.length < 2) {
alert("Please draw a path first!");
return;
}
isFollowing = true;
targetPointIndex = 0;
executePathStep();
}

function executePathStep() {
if (!isFollowing || targetPointIndex >= drawnPath.length) { stop(); return; }
const command = drawnPath[targetPointIndex];
updateMotorValues(command.forward, command.turn);
targetPointIndex++;
setTimeout(executePathStep, 50);
}

function processJoystick(clientX, clientY) {
isFollowing = false; // Manual override
if(drawMode) {
const rect = container.getBoundingClientRect();
let dx = clientX - rect.left - centerX;
let dy = centerY - (clientY - rect.top);
let distance = Math.sqrt(dx*dx + dy*dy);
if (distance > maxRadius) { dx *= maxRadius / distance; dy *= maxRadius / distance; }

let forward = dy / maxRadius;
let turn = dx / maxRadius;

drawnPath.push({forward: forward, turn: turn});

// Simulate movement for drawing the path
let simulatedL = parseInt((forward + turn) * 1023);
let simulatedR = parseInt((forward - turn) * 1023);

const v = (simulatedL + simulatedR) / 2 * speedScale;
const w = (simulatedL - simulatedR) * turnScale;

simulatedDrawHeading += w;
simulatedDrawX += v * Math.cos(simulatedDrawHeading);
simulatedDrawY += v * Math.sin(simulatedDrawHeading);

drawnPathCoordinates.push({x: simulatedDrawX, y: simulatedDrawY});
return; // Do not send motor commands when in draw mode
}
const rect = container.getBoundingClientRect();
let dx = clientX - rect.left - centerX;
let dy = centerY - (clientY - rect.top);
let distance = Math.sqrt(dx*dx + dy*dy);
if (distance > maxRadius) { dx *= maxRadius / distance; dy *= maxRadius / distance; }
moveJoystick(centerX + dx, centerY - dy);
let forward = dy / maxRadius, turn = dx / maxRadius;
updateMotorValues(forward, turn);
}

function updateMotorValues(forward, turn) {
let leftMotor = Math.max(-1, Math.min(1, forward + turn));
let rightMotor = Math.max(-1, Math.min(1, forward - turn));
let newL = parseInt(leftMotor * 1023);
let newR = parseInt(rightMotor * 1023);

if (newL !== curL || newR !== curR) {
curL = newL;
curR = newR;
sendMotor(curL, curR);
}
}

function updateTrajectory() {
if (curL !== 0 || curR !== 0) {
const v = (curL + curR) / 2 * speedScale;
const w = (curL - curR) * turnScale;

roverHeading += w;
roverX += v * Math.cos(roverHeading);
roverY += v * Math.sin(roverHeading);

path.push({x: roverX, y: roverY});
if (path.length > 3000) path.shift();
}

ctx.fillStyle = "black";
ctx.fillRect(0, 0, canvas.width, canvas.height);

// Draw user-drawn path if it exists
if (drawnPathCoordinates.length > 0) {
ctx.strokeStyle = "#f1c40f"; // Yellow for the drawn path
ctx.beginPath();
ctx.moveTo(drawnPathCoordinates[0].x, drawnPathCoordinates[0].y);
drawnPathCoordinates.forEach(p => ctx.lineTo(p.x, p.y));
ctx.stroke();
}

ctx.strokeStyle = "#3498db";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(path[0].x, path[0].y);
for (let i = 1; i < path.length; i++) {
ctx.lineTo(path[i].x, path[i].y);
}
ctx.stroke();

// Draw Rover Pointer (Orientation)
ctx.save();
ctx.translate(roverX, roverY);
ctx.rotate(roverHeading);
ctx.fillStyle = "#ff4757"; // Red pointer for visibility
ctx.beginPath();
ctx.moveTo(12, 0); // Tip
ctx.lineTo(-8, -8);
ctx.lineTo(-8, 8);
ctx.closePath();
ctx.fill();
ctx.restore();

requestAnimationFrame(updateTrajectory);
}

const start = (e) => { dragging = true; processJoystick(e.clientX || e.touches[0].clientX, e.clientY || e.touches[0].clientY); };
const move = (e) => { if (dragging) { if(e.touches) e.preventDefault(); processJoystick(e.clientX || e.touches[0].clientX, e.clientY || e.touches[0].clientY); } };
const stop = () => { dragging = false; isFollowing = false; moveJoystick(centerX, centerY); curL = 0; curR = 0; sendMotor(0,0); };

const keys = {};
const updateKeyboardControl = () => {
isFollowing = false; // Manual override
if (dragging) return; // Joystick takes priority
let forward = 0, turn = 0;
if (keys['w'] || keys['arrowup']) forward += 1;
if (keys['s'] || keys['arrowdown']) forward -= 1;
if (keys['a'] || keys['arrowleft']) turn -= 1;
if (keys['d'] || keys['arrowright']) turn += 1;

if (drawMode) { // If in draw mode, record commands and simulate path
drawnPath.push({forward: forward, turn: turn});

// Simulate movement for drawing the path
let simulatedL = parseInt((forward + turn) * 1023);
let simulatedR = parseInt((forward - turn) * 1023);

const v = (simulatedL + simulatedR) / 2 * speedScale;
const w = (simulatedL - simulatedR) * turnScale;

simulatedDrawHeading += w;
simulatedDrawX += v * Math.cos(simulatedDrawHeading);
simulatedDrawY += v * Math.sin(simulatedDrawHeading);

drawnPathCoordinates.push({x: simulatedDrawX, y: simulatedDrawY});
return;
}

// Sync keyboard to visual joystick
moveJoystick(centerX + turn * maxRadius, centerY - forward * maxRadius);
updateMotorValues(forward, turn);
};

document.addEventListener("keydown", (e) => { keys[e.key.toLowerCase()] = true; updateKeyboardControl(); });
document.addEventListener("keyup", (e) => { keys[e.key.toLowerCase()] = false; updateKeyboardControl(); });

container.addEventListener("mousedown", start);
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", stop);
container.addEventListener("touchstart", start);
container.addEventListener("touchmove", move);
container.addEventListener("touchend", stop);

updateTrajectory();
</script>
</body>
</html>
)=====";

Testing

Screenshot from 2026-07-05 16-00-57.png
Screenshot from 2026-07-05 16-01-42.png
WhatsApp Image 2026-07-05 at 16.41.25.jpeg
IMG-20260612-WA0097.jpg

Establishing Control Link

  1. Connect the host workstation or mobile device to the wireless access point: SSID: micro_rover, Password: 12345678.
  2. Navigate to http://192.168.4.1 or http://micro-rover.local to start the interface.


Advanced Controls

IMG-20260603-WA0072.jpg
WhatsApp Image 2026-07-05 at 16.41.59.jpeg

Automated Python Automation Integration

Clone the repository https://github.com/RemanDey/micro-rover

git clone https://github.com/RemanDey/micro-rover.git
cd micro-rover/scripts

The scripts/ directory includes interface wrappers for remote script execution:

  1. keyboard_control.py: Low-latency keyboard command parsing.
  2. dance_sequence.py: Scripted pattern automation.
  3. cv_target_tracking.py: Target tracking integration template.
  4. trace_shapes.py: Geometric path tracing algorithms.

Run and test any of them by :

python keyboard_control.py