Have you ever wanted to build a smart 3D printed robot arm that can pick and place objects wirelessly from your phone or laptop?
This arm can move in multiple directions, rotate the base, bend the shoulder and elbow, control the wrist, and open or close the gripper.
User connects to the ESP32 Wi-Fi hotspot.
The browser opens a custom control page.
The base joystick controls the continuous rotation servo.
The other joints are controlled using compact pulley-style controls.
ESP32 receives the commands and moves the servos accordingly.
Done! The robot arm becomes a fully wireless pick and place system.
The ESP32 creates a web page, receives control commands, and drives the robot arm servos.
#include <WiFi.h>
#include <WebServer.h>
#include <ESP32Servo.h>
const char* ssid = "RobotArm-ESP32";
const char* password = "12345678";
WebServer server(80);
Servo servos[6];
// Pins
const int servoPins[6] = {13, 14, 27, 26, 25, 33};
// Current values
int servoValue[6] = {90, 90, 90, 90, 90, 40};
// Safe limits
const int servoMin[6] = {0, 15, 10, 20, 0, 20};
const int servoMax[6] = {180, 165, 170, 160, 180, 90};
// Reverse direction if needed
const bool invertServo[6] = {false, false, false, false, false, false};
// Base continuous servo tuning
int BASE_STOP = 90;
unsigned long lastBaseCmdMs = 0;
int lastBaseCmdValue = 90;
// ------------------ Helpers ------------------
int applyAngle(int i, int angle) {
angle = constrain(angle, 0, 180);
if (invertServo[i]) angle = 180 - angle;
return constrain(angle, servoMin[i], servoMax[i]);
}
void writePosServo(int i, int angle) {
servoValue[i] = constrain(angle, 0, 180);
servos[i].write(applyAngle(i, servoValue[i]));
}
void writeBase(int value) {
value = constrain(value, 0, 180);
servoValue[0] = value;
lastBaseCmdValue = value;
lastBaseCmdMs = millis();
servos[0].write(value);
}
void stopBase() {
writeBase(BASE_STOP);
}
void moveSmoothTo(int s1, int s2, int s3, int s4, int s5, int stepDelayMs = 10) {
int target[6] = {servoValue[0], s1, s2, s3, s4, s5};
bool done = false;
while (!done) {
done = true;
for (int i = 1; i < 6; i++) {
if (servoValue[i] < target[i]) {
servoValue[i]++;
done = false;
} else if (servoValue[i] > target[i]) {
servoValue[i]--;
done = false;
}
servos[i].write(applyAngle(i, servoValue[i]));
}
delay(stepDelayMs);
}
}
// ------------------ Presets ------------------
void goHome() {
stopBase();
moveSmoothTo(90, 90, 90, 90, 40);
}
void openGripper() {
writePosServo(5, 70);
}
void closeGripper() {
writePosServo(5, 20);
}
void pickSequence() {
openGripper();
delay(200);
moveSmoothTo(115, 120, 95, 90, 70);
delay(150);
moveSmoothTo(135, 140, 90, 90, 70);
delay(200);
closeGripper();
delay(400);
moveSmoothTo(110, 115, 95, 90, 20);
}
void placeSequence() {
moveSmoothTo(115, 120, 95, 90, 20);
delay(150);
moveSmoothTo(135, 140, 90, 90, 20);
delay(200);
openGripper();
delay(400);
moveSmoothTo(110, 115, 95, 90, 70);
}
// ------------------ HTML UI ------------------
const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ESP32 Robot Arm Control</title>
<style>
:root{
--bg1:#07111d;
--bg2:#0d2237;
--card:rgba(255,255,255,0.08);
--line:rgba(255,255,255,0.12);
--text:#eef5ff;
--muted:#aabed6;
--blue:#4aa3ff;
--purple:#7c5cff;
--green:#22c55e;
--orange:#f97316;
--shadow:0 18px 50px rgba(0,0,0,0.30);
}
*{box-sizing:border-box;}
body{
margin:0;
min-height:100vh;
font-family:Arial, Helvetica, sans-serif;
color:var(--text);
background:
radial-gradient(circle at top left, rgba(74,163,255,0.16), transparent 24%),
radial-gradient(circle at bottom right, rgba(124,92,255,0.16), transparent 30%),
linear-gradient(135deg,var(--bg1),var(--bg2));
padding:16px;
}
.wrap{
max-width:1200px;
margin:auto;
display:grid;
gap:14px;
}
.top{
padding:18px 20px;
border-radius:22px;
background:linear-gradient(135deg, rgba(255,255,255,0.10), rgba(255,255,255,0.05));
border:1px solid var(--line);
box-shadow:var(--shadow);
backdrop-filter:blur(14px);
}
h1{
margin:0 0 6px;
font-size:clamp(26px,3vw,40px);
}
.sub{
margin:0;
color:var(--muted);
line-height:1.5;
font-size:14px;
}
.status{
display:flex;
flex-wrap:wrap;
gap:8px;
margin-top:12px;
}
.pill{
padding:7px 11px;
border-radius:999px;
background:rgba(255,255,255,0.08);
border:1px solid var(--line);
color:var(--muted);
font-size:13px;
}
.grid{
display:grid;
grid-template-columns:320px 1fr;
gap:14px;
}
@media(max-width:900px){
.grid{ grid-template-columns:1fr; }
}
.card{
padding:14px;
border-radius:20px;
background:var(--card);
border:1px solid var(--line);
box-shadow:var(--shadow);
backdrop-filter:blur(14px);
}
.card h2{
margin:0 0 12px;
font-size:19px;
}
/* Base joystick */
.baseArea{
display:flex;
flex-direction:column;
align-items:center;
gap:10px;
}
.joy{
width:220px;
height:220px;
border-radius:50%;
position:relative;
background:radial-gradient(circle at 35% 35%, rgba(255,255,255,0.15), rgba(255,255,255,0.05));
border:1px solid rgba(255,255,255,0.14);
box-shadow:inset 0 0 26px rgba(0,0,0,0.24);
touch-action:none;
user-select:none;
}
.joy::before{
content:"";
position:absolute;
inset:20px;
border-radius:50%;
border:1px dashed rgba(255,255,255,0.14);
}
.thumb{
width:72px;
height:72px;
border-radius:50%;
position:absolute;
left:50%;
top:50%;
transform:translate(-50%,-50%);
background:linear-gradient(135deg,var(--blue),var(--purple));
box-shadow:0 12px 24px rgba(0,0,0,0.24);
display:flex;
align-items:center;
justify-content:center;
font-weight:700;
letter-spacing:0.5px;
}
.state{
font-size:18px;
font-weight:700;
}
.small{
color:var(--muted);
font-size:13px;
text-align:center;
line-height:1.4;
}
.btnRow{
width:100%;
display:grid;
grid-template-columns:repeat(3,1fr);
gap:8px;
margin-top:6px;
}
button{
border:none;
border-radius:14px;
color:white;
font-weight:700;
cursor:pointer;
padding:12px 12px;
font-size:15px;
transition:transform .12s ease, opacity .12s ease;
box-shadow:0 10px 20px rgba(0,0,0,0.16);
}
button:active{ transform:translateY(1px); opacity:.92; }
.bBlue{ background:linear-gradient(135deg,#3b82f6,#7c5cff); }
.bGreen{ background:linear-gradient(135deg,#22c55e,#16a34a); }
.bOrange{ background:linear-gradient(135deg,#f97316,#ea580c); }
.bDark{ background:rgba(255,255,255,0.12); }
/* Compact pulley controls */
.jointGrid{
display:grid;
grid-template-columns:repeat(auto-fit, minmax(180px, 1fr));
gap:10px;
}
.joint{
padding:12px;
border-radius:16px;
background:rgba(255,255,255,0.06);
border:1px solid rgba(255,255,255,0.08);
}
.jointTop{
display:flex;
justify-content:space-between;
align-items:center;
margin-bottom:10px;
font-size:14px;
}
.jointTop strong{ font-size:14px; }
.angle{
color:var(--muted);
font-size:13px;
}
.pulley{
display:grid;
grid-template-columns:42px 1fr 42px;
gap:8px;
align-items:center;
}
.pm{
height:44px;
padding:0;
border-radius:12px;
font-size:22px;
line-height:44px;
}
.range{
width:100%;
accent-color:var(--blue);
}
.rangeBox{
padding:8px 0 0;
}
.footerBtns{
display:grid;
grid-template-columns:repeat(auto-fit, minmax(110px,1fr));
gap:8px;
}
.note{
margin-top:10px;
color:var(--muted);
font-size:12px;
line-height:1.5;
}
</style>
</head>
<body>
<div class="wrap">
<div class="top">
<h1>ESP32 Robot Arm Control</h1>
<p class="sub">Base uses a live joystick. Shoulder, elbow, wrist pitch, wrist roll, and gripper use compact pulley controls for quick pick-and-place movement.</p>
<div class="status">
<div class="pill">AP: RobotArm-ESP32</div>
<div class="pill">IP: 192.168.4.1</div>
<div class="pill" id="netStatus">Status: Ready</div>
</div>
</div>
<div class="grid">
<div class="card">
<h2>Base Joystick</h2>
<div class="baseArea">
<div class="joy" id="baseJoy">
<div class="thumb" id="baseThumb">BASE</div>
</div>
<div class="state" id="baseState">STOP</div>
<div class="small">Drag left/right. Release to stop.</div>
<div class="btnRow">
<button class="bBlue" id="leftBtn">LEFT</button>
<button class="bGreen" id="stopBtn">STOP</button>
<button class="bOrange" id="rightBtn">RIGHT</button>
</div>
</div>
</div>
<div class="card">
<h2>Compact Servo Controls</h2>
<div class="jointGrid">
<div class="joint">
<div class="jointTop"><strong>Shoulder</strong><span class="angle" id="v1">90°</span></div>
<div class="pulley">
<button class="pm bDark" onclick="stepServo(1,-1)">−</button>
<input class="range" type="range" id="s1" min="15" max="165" value="90" oninput="setServo(1,this.value)">
<button class="pm bDark" onclick="stepServo(1,1)">+</button>
</div>
</div>
<div class="joint">
<div class="jointTop"><strong>Elbow</strong><span class="angle" id="v2">90°</span></div>
<div class="pulley">
<button class="pm bDark" onclick="stepServo(2,-1)">−</button>
<input class="range" type="range" id="s2" min="10" max="170" value="90" oninput="setServo(2,this.value)">
<button class="pm bDark" onclick="stepServo(2,1)">+</button>
</div>
</div>
<div class="joint">
<div class="jointTop"><strong>Wrist Pitch</strong><span class="angle" id="v3">90°</span></div>
<div class="pulley">
<button class="pm bDark" onclick="stepServo(3,-1)">−</button>
<input class="range" type="range" id="s3" min="20" max="160" value="90" oninput="setServo(3,this.value)">
<button class="pm bDark" onclick="stepServo(3,1)">+</button>
</div>
</div>
<div class="joint">
<div class="jointTop"><strong>Wrist Roll</strong><span class="angle" id="v4">90°</span></div>
<div class="pulley">
<button class="pm bDark" onclick="stepServo(4,-1)">−</button>
<input class="range" type="range" id="s4" min="0" max="180" value="90" oninput="setServo(4,this.value)">
<button class="pm bDark" onclick="stepServo(4,1)">+</button>
</div>
</div>
<div class="joint">
<div class="jointTop"><strong>Gripper</strong><span class="angle" id="v5">40°</span></div>
<div class="pulley">
<button class="pm bDark" onclick="stepServo(5,-1)">−</button>
<input class="range" type="range" id="s5" min="20" max="90" value="40" oninput="setServo(5,this.value)">
<button class="pm bDark" onclick="stepServo(5,1)">+</button>
</div>
</div>
</div>
<div class="note">
Use the pulley controls for quick fine adjustment. This keeps the page compact and easier to use on mobile.
</div>
<div class="footerBtns" style="margin-top:12px;">
<button class="bBlue" onclick="preset('home')">Home</button>
<button class="bGreen" onclick="preset('pick')">Pick</button>
<button class="bOrange" onclick="preset('place')">Place</button>
<button class="bDark" onclick="preset('open')">Open</button>
<button class="bDark" onclick="preset('close')">Close</button>
<button class="bDark" onclick="refreshState()">Refresh</button>
</div>
</div>
</div>
</div>
<script>
const netStatus = document.getElementById('netStatus');
const baseState = document.getElementById('baseState');
const baseJoy = document.getElementById('baseJoy');
const baseThumb = document.getElementById('baseThumb');
let lastBaseValue = 90;
let lastBaseSend = 0;
function setNet(t){ netStatus.innerText = t; }
async function fetchOK(url){
const r = await fetch(url);
return r.ok;
}
async function setServo(i, v){
v = Math.max(0, Math.min(180, parseInt(v)));
document.getElementById('v' + i).innerText = v + '°';
try{
await fetchOK(`/set?i=${i}&v=${v}`);
setNet('Status: Connected');
}catch(e){
setNet('Status: Offline');
}
}
function stepServo(i, delta){
const s = document.getElementById('s' + i);
const next = parseInt(s.value) + delta;
s.value = next;
setServo(i, next);
}
async function preset(name){
try{
setNet('Status: Running ' + name);
await fetchOK(`/preset?name=${name}`);
await refreshState();
setNet('Status: Connected');
}catch(e){
setNet('Status: Offline');
}
}
async function refreshState(){
try{
const res = await fetch('/state');
const data = await res.json();
for(let i=0;i<6;i++){
const s = document.getElementById('s'+i);
const v = document.getElementById('v'+i);
if(s) s.value = data['s'+i];
if(v) v.innerText = data['s'+i] + '°';
}
baseState.innerText = 'STOP';
resetBaseThumb();
setNet('Status: Connected');
}catch(e){
setNet('Status: Offline');
}
}
async function sendBase(v){
v = Math.max(0, Math.min(180, Math.round(v)));
const now = Date.now();
if(v === lastBaseValue && (now - lastBaseSend) < 35) return;
lastBaseValue = v;
lastBaseSend = now;
const offset = v - 90;
if(Math.abs(offset) < 4) baseState.innerText = 'STOP';
else if(offset < 0) baseState.innerText = 'LEFT ' + Math.abs(offset);
else baseState.innerText = 'RIGHT ' + Math.abs(offset);
try{
await fetchOK(`/base?v=${v}`);
setNet('Status: Connected');
}catch(e){
setNet('Status: Offline');
}
}
function resetBaseThumb(){
baseThumb.style.transform = 'translate(-50%, -50%)';
}
function setupBaseJoystick(){
let dragging = false;
function updateBase(clientX, clientY){
const rect = baseJoy.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
let dx = clientX - cx;
let dy = clientY - cy;
const radius = rect.width / 2 - 40;
const dist = Math.sqrt(dx*dx + dy*dy);
if(dist > radius){
dx = dx * radius / dist;
dy = dy * radius / dist;
}
baseThumb.style.transform = `translate(${dx}px, ${dy}px) translate(-50%, -50%)`;
const xNorm = dx / radius;
const dead = 0.14;
if(Math.abs(xNorm) < dead){
sendBase(90);
return;
}
const sign = xNorm > 0 ? 1 : -1;
const speed = (Math.abs(xNorm) - dead) / (1 - dead);
const offset = 10 + Math.round(speed * 20);
sendBase(90 + sign * offset);
}
function stopBaseJoy(){
dragging = false;
resetBaseThumb();
sendBase(90);
}
baseJoy.addEventListener('pointerdown', (e) => {
dragging = true;
baseJoy.setPointerCapture(e.pointerId);
updateBase(e.clientX, e.clientY);
});
baseJoy.addEventListener('pointermove', (e) => {
if(!dragging) return;
updateBase(e.clientX, e.clientY);
});
baseJoy.addEventListener('pointerup', stopBaseJoy);
baseJoy.addEventListener('pointercancel', stopBaseJoy);
baseJoy.addEventListener('pointerleave', stopBaseJoy);
document.getElementById('leftBtn').addEventListener('click', () => sendBase(60));
document.getElementById('stopBtn').addEventListener('click', () => sendBase(90));
document.getElementById('rightBtn').addEventListener('click', () => sendBase(120));
}
window.addEventListener('load', () => {
setupBaseJoystick();
refreshState();
});
</script>
</body>
</html>
)rawliteral";
// ------------------ Web handlers ------------------
void handleRoot() {
server.send_P(200, "text/html", index_html);
}
void handleSet() {
if (!server.hasArg("i") || !server.hasArg("v")) {
server.send(400, "text/plain", "Missing i or v");
return;
}
int i = server.arg("i").toInt();
int v = server.arg("v").toInt();
if (i < 1 || i > 5) {
server.send(400, "text/plain", "Servo index must be 1 to 5");
return;
}
writePosServo(i, v);
server.send(200, "text/plain", "OK");
}
void handleBase() {
if (!server.hasArg("v")) {
server.send(400, "text/plain", "Missing v");
return;
}
int v = server.arg("v").toInt();
v = constrain(v, 0, 180);
writeBase(v);
server.send(200, "text/plain", "OK");
}
void handleState() {
String json = "{";
for (int i = 0; i < 6; i++) {
json += "\"s" + String(i) + "\":" + String(servoValue[i]);
if (i < 5) json += ",";
}
json += "}";
server.send(200, "application/json", json);
}
void handlePreset() {
if (!server.hasArg("name")) {
server.send(400, "text/plain", "Missing name");
return;
}
String name = server.arg("name");
if (name == "home") {
goHome();
} else if (name == "pick") {
pickSequence();
} else if (name == "place") {
placeSequence();
} else if (name == "open") {
openGripper();
} else if (name == "close") {
closeGripper();
} else {
server.send(400, "text/plain", "Unknown preset");
return;
}
server.send(200, "text/plain", "OK");
}
void setup() {
Serial.begin(115200);
for (int i = 0; i < 6; i++) {
servos[i].setPeriodHertz(50);
servos[i].attach(servoPins[i], 500, 2400);
}
stopBase();
writePosServo(1, 90);
writePosServo(2, 90);
writePosServo(3, 90);
writePosServo(4, 90);
writePosServo(5, 40);
WiFi.mode(WIFI_AP);
WiFi.softAP(ssid, password);
Serial.println();
Serial.println("ESP32 Robot Arm Started");
Serial.print("AP IP: ");
Serial.println(WiFi.softAPIP());
server.on("/", handleRoot);
server.on("/set", handleSet);
server.on("/base", handleBase);
server.on("/state", handleState);
server.on("/preset", handlePreset);
server.begin();
}
void loop() {
server.handleClient();
if (millis() - lastBaseCmdMs > 400 && lastBaseCmdValue != BASE_STOP) {
stopBase();
}
}
This makes the interface clean, professional, and easy to use on mobile or laptop.
The base is controlled using a joystick because it is a 360° continuous servo.
The shoulder, elbow, wrist pitch, wrist roll, and gripper are controlled using compact pulley-style controls.
First, I 3D printed all the robot arm parts and assembled the base, shoulder, elbow, wrist, and gripper section by section.
After assembly, I mounted the servos and checked each joint movement manually.
Then I connected the ESP32 and uploaded the code.
Finally, I opened the web interface and tested the full pick and place movement.
Looking for custom 3D printed projects, robotic parts, prototypes, or engineering models?
Comments
Post a Comment