Hardware Projects/Dum-E

Dum-E

A self-designed robotic arm built entirely from scratch — through four confirmed stages from basic serial control to a WiFi web dashboard and a wearable ESP-NOW glove controller.

ESP32
Robotics
3D Printing
ESP-NOW
IMU
Servo Control
C++
PCA9685
Dum-E Robotic Arm

Overview

Dum-E is a personal, self-designed robotic arm built to explore embedded systems, wireless communication, and real-time control — named after the loyal robotic arm from the Iron Man franchise. Unlike typical hobby arm kits, every firmware decision, wiring choice, calibration constant, and software architecture pattern was arrived at through iterative, hardware-in-the-loop testing.

The project is structured in four discrete, confirmed stages. Each stage is fully validated before the next begins — a discipline that ensures a stable, well-understood baseline at every phase of development.

Key Specifications

ParameterValue
Axes of Motion6
Main ControllerXIAO ESP32 S3
Glove ControllerXIAO ESP32 C3
PWM DriverPCA9685 (I2C, 0x40)
PWM Frequency60 Hz
Power SupplyExternal 5V, 3A
Control ModesWiFi Web UI, ESP-NOW Glove
CommunicationWiFi (HTTP), ESP-NOW, I2C
IMUMPU6050 (Pitch + Roll only)
Flex Sensors2× resistive
Firmware LanguageC++ (Arduino framework)
Motion ArchitectureNon-blocking (millis()-based)

Joint Layout

JointServoChannelRangeHome
S1 — WaistMG995CH00° – 180°90°
S2 — ShoulderMG995CH145° – 135°90°
S3 — ElbowMG995CH230° – 150°90°
S4 — Wrist RollMG90SCH30° – 180°90°
S5 — Wrist PitchSG90CH445° – 135°110°
S6 — GripSG90CH520° – 100°60°

What Makes Dum-E Different

  • No kit dependency. Every subsystem — mechanical, electrical, and software — was independently designed or selected.
  • Stage-by-stage confirmation. Each stage is confirmed fully working before the next begins. When something breaks in a new stage, the root cause search is bounded to what changed.
  • Non-blocking firmware. All six servos move simultaneously using a millis()-based engine, keeping the WiFi server fully responsive during motion.
  • Physically-tuned per-servo motion. Step delay and step size were determined through actual physical testing, not theory, to eliminate stiction on the MG995 joints.
  • Dual-mode glove control. Two motion modes toggled by a single flex sensor, controlling four joints with one IMU and two flex sensors.
  • Confirmed calibration. PWM constants (SERVOMIN=125, SERVOMAX=625) were physically validated once in Stage 1 and have never been recalculated.

Components & Bill of Materials

Main Arm Electronics

ComponentRoleSpecs / Notes
XIAO ESP32 S3Main arm controllerDual-core 240 MHz, WiFi + ESP-NOW, I2C
PCA9685 PWM DriverServo signal generator16-channel, I2C, 0x40, 60 Hz
External 5V 3A PSUServo power supplyDedicated; all 6 servos stable at 3A
MG995 × 3Heavy-duty servos (S1–S3)9–11 kg·cm torque, metal gear
MG90S × 1Mid-weight servo (S4)Metal gear, lighter than MG995
SG90 × 2Light servos (S5, S6)Plastic gear; wrist pitch and grip

Glove Controller Electronics

ComponentRoleSpecs / Notes
XIAO ESP32 C3Glove microcontrollerESP-NOW transmitter, compact form factor
MPU60506-axis IMUPitch + Roll only — Yaw excluded
Flex Sensor × 2Finger bend detectionResistive; voltage divider wired
10 kΩ resistor × 2Voltage divider pull-downSubstitute 47 kΩ if ADC delta < 100
LiPo / USB powerGlove power sourceWrist-mounted or tethered

Software Libraries

LibraryUsed For
Adafruit PWMServoDriverPCA9685 I2C control
WebServer (ESP32)WiFi HTTP server on S3
esp_now.hESP-NOW transmitter and receiver
Wire.hI2C communication
WiFi.hWiFi stack on S3
MPU6050 libraryIMU raw data access on C3

Hardware Model

Interactive 3D view of each 3D-printed component. Select a part below to inspect it — drag to rotate, scroll to zoom.

Loading 3D viewer...

Stage 1 — Serial Monitor Control
Completed

Stage 1 is the foundation. No wireless control, no simultaneous motion — just a rock-solid baseline that proves the hardware stack works and locks in the calibration constants that carry forward permanently. Control is via Arduino Serial Monitor using commands like S1-90 or S1-90, S2-45. Motion is smooth but sequential and blocking.

Hardware: Arduino Uno R3 + PCA9685 PWM driver (I2C, 0x40) + external 5V 3A PSU + all 6 servos. PCA9685 was chosen from the start over onboard PWM pins — it generates stable 60 Hz independently over I2C, keeping servo timing clean regardless of CPU load, and scales forward without hardware changes.

PWM Calibration — The Permanent Constants

SERVOMIN and SERVOMAX are the most important constants in the entire project. Determined physically by commanding each servo to 0° and 180° and observing actual sweep. At these values, no servo buzzed at endpoints and full mechanical range was achieved. These values have never been recalculated since.

Stage1_SerialMonitor.ino — calibration constants
#define SERVOMIN 125   // physically validated — never change
#define SERVOMAX 625
#define PWM_FREQ  60   // Hz

int angleToPulse(int angle) {
  return map(angle, 0, 180, SERVOMIN, SERVOMAX);
}

Per-Joint Limits & Home Positions

Each servo's limits were set by physically driving joints to their mechanical boundaries and observing where binding or structural collision occurred. S2 (Shoulder) constrained to 45°–135° to prevent upper-arm binding. S5 (Wrist Pitch) has a home of 110° rather than 90° — correcting a mechanical offset in the wrist assembly geometry that was discovered during physical testing.

Stage1_SerialMonitor.ino — joint configuration
// Index 0 unused — servos numbered 1–6
const uint8_t SERVO_CH[7]        = {255,  0,   1,   2,   3,   4,   5  };
const uint8_t SERVO_MIN_ANGLE[7] = {  0,  0,  45,  30,   0,  45,  20 };
const uint8_t SERVO_MAX_ANGLE[7] = {  0,180, 135, 150, 180, 135, 100 };
const uint8_t SERVO_HOME[7]      = {  0, 90,  90,  90,  90, 110,  60 };

Startup Sequence

On boot, all servos are snapped directly to home via raw PWM writes (bypassing the smooth motion engine) before any commands are accepted. A 500 ms gap between each snap prevents simultaneous inrush current from all six servos on cold-start.

Stage1_SerialMonitor.ino — syncAndHome()
void syncAndHome() {
  for (uint8_t i = 1; i <= 6; i++) {
    board1.setPWM(SERVO_CH[i], 0, angleToPulse(SERVO_HOME[i]));
    currentAngle[i] = SERVO_HOME[i];
    delay(500);  // prevents simultaneous inrush current
  }
  homeAllServos();  // smooth confirmation pass
}

Smooth Single-Servo Move

Every commanded move is incremental — the servo steps from its current angle to the target 1°/step with a 35 ms delay, producing a gliding motion rather than a snap. Angle clamping happens once before the loop begins.

Stage1_SerialMonitor.ino — moveServoSmooth()
void moveServoSmooth(uint8_t servoNum, uint8_t target) {
  uint8_t limited = constrain(target,
                    SERVO_MIN_ANGLE[servoNum],
                    SERVO_MAX_ANGLE[servoNum]);
  float current = currentAngle[servoNum];

  while ((int)current != (int)limited) {
    if (current < limited) current += STEP_SIZE;
    else                   current -= STEP_SIZE;
    current = constrain(current,
              SERVO_MIN_ANGLE[servoNum], SERVO_MAX_ANGLE[servoNum]);
    board1.setPWM(SERVO_CH[servoNum], 0, angleToPulse((int)current));
    delay(STEP_DELAY_MS);  // 35 ms global — per-servo tuning comes in Stage 2
  }
  currentAngle[servoNum] = limited;
}

Stage 1 Challenges

PWM CalibrationWithout confirmed SERVOMIN/SERVOMAX, servos went to wrong angles or buzzed at endpoints. Each servo was manually swept to find the correct PWM counts for 0° and 180°. Values confirmed, locked, never touched again.
Mechanical BindingS2 and S3 at full range caused structural collisions. Per-joint limits defined by physical observation and encoded in software via constrain(). Out-of-range commands are clamped with a [LIMIT] warning — not rejected.
S5 Wrist Pitch Home OffsetSetting S5 to 90° placed the wrist in a mechanically awkward position due to horn installation geometry. Home adjusted to 110° — encoded in SERVO_HOME[5] and reflected in every subsequent stage.
What Stage 2 fixes: Single global STEP_DELAY (35 ms) and STEP_SIZE (1°) applied to all servos regardless of torque. Sequential-only motion. Blocking delay() in the motion loop. Arduino Uno has no WiFi. All of these become problems in Stage 2.

Stage 2 — Motion Engine (V1 → V2)
Completed

Stage 2 is the largest architectural leap in the project. Everything about how the arm moves changes here. Stage 1 proved the hardware worked; Stage 2 makes it move well. Two firmware versions were developed in sequence — V1 introduces per-servo tuning and conservative simultaneous motion; V2 removes all restrictions and achieves full 6-servo simultaneous movement.

Hardware Changes

The Arduino Uno is replaced by the XIAO ESP32 S3 — not because WiFi is used yet, but to contain the controller migration here rather than mid-Stage 3 when it would complicate debugging.

ComponentStage 1Stage 2
MicrocontrollerArduino Uno R3XIAO ESP32 S3
I2C Pins (SDA/SCL)A4 / A5 (default Uno)D4/GPIO6, D5/GPIO7 — explicitly declared
PWM DriverPCA9685 @ 0x40PCA9685 @ 0x40 (unchanged)
PWM CalibrationSERVOMIN=125, SERVOMAX=625Same — never recalculated
Servo HardwareMG995×3, MG90S×1, SG90×2Same 6 servos (unchanged)
Serial Baud Rate96009600 (upgraded to 115200 in Stage 3)

The Core Problem — MG995 Stiction

When the initial simultaneous motion prototype used Stage 1's uniform 35 ms delay, the MG995 servos (S1, S2, S3) jerked in visible discrete steps rather than gliding. The root cause is counterintuitive:

Too long a delay: The servo fully settles between PWM updates. The next update arrives from "rest" — static friction (stiction) must be overcome again. Result: discrete visible lurches.

Too short a delay: PWM updates arrive faster than the servo's internal feedback loop. Overshoot and correction repeatedly. Result: buzzing.

Correct delay: Updates arrive frequently enough that the motor never fully settles — it's always in motion, just slowly guided. Static friction is never re-engaged. Result: smooth glide.

This insight — that the PWM update rate directly controls whether the servo is fighting stiction or flowing through it — is the core discovery of Stage 2 and shaped every subsequent stage.

Per-Servo Tuning Constants

Stage2 — per-servo tuning arrays
// Index 0 unused — confirmed through physical iteration
const uint8_t STEP_DELAY[7] = {0,  40,  30,  30,  25,  25,  25};
const float   STEP_SIZE[7]  = {0, 1.0, 0.5, 0.5, 1.0, 1.0, 1.0};

// S1 (Waist, MG995):   40 ms / 1.0° — heavier base needs slightly slower updates
// S2 (Shoulder, MG995): 30 ms / 0.5° — fine control for load-bearing lift joint
// S3 (Elbow, MG995):   30 ms / 0.5° — same reasoning as S2
// S4–S6 (lighter):     25 ms / 1.0° — faster and more responsive

The finer 0.5°/step on S2 and S3 produces more intermediate PWM positions during load-bearing moves, keeping the motor continuously guided rather than snapping between widely-spaced targets under torque.

V1 — Conservative Simultaneous Motion

V1 introduces simultaneous motion but only for the three lighter servos (S4, S5, S6). MG995 servos still move sequentially with 100 ms settling delays between each, based on concern about the 3A supply handling three MG995s simultaneously. An IS_MG995[] flag classifies each servo and separates commands into heavy/light groups before dispatch.

Stage2_1_MotionEngine1.ino — moveSimultaneous() V1
void moveSimultaneous(Move* moves, uint8_t count) {
  // Reject MG995 servos — fall back to sequential
  for (uint8_t i = 0; i < count; i++) {
    if (IS_MG995[moves[i].servo]) {
      for (uint8_t j = 0; j < count; j++)
        moveServoSmooth(moves[j].servo, moves[j].target);
      return;
    }
  }

  bool anyActive = true;
  while (anyActive) {
    anyActive = false;
    for (uint8_t i = 0; i < count; i++) {
      uint8_t s = moves[i].servo;
      if ((int)current[i] == (int)limited[i]) continue;
      if (current[i] < limited[i]) current[i] += STEP_SIZE[s];
      else                         current[i] -= STEP_SIZE[s];
      board1.setPWM(SERVO_CH[s], 0, angleToPulse((int)current[i]));
      if ((int)current[i] != (int)limited[i]) anyActive = true;
    }
    delay(25);  // fixed — SG90/MG90S safe rate
  }
}

V2 — Full Simultaneous Motion

V1's restriction was physically tested: three MG995s commanded simultaneously — no brownout, no reset. The 3A supply proved adequate. V2 removes IS_MG995[] entirely. All servos move simultaneously. A key improvement: the tick delay is now dynamically calculated as the maximum STEP_DELAY among active servos — slower servos set the shared tick rate, and as faster servos complete and drop out, the rate naturally decreases.

Stage2_2_MotionEngine2.ino — moveSimultaneous() V2
void moveSimultaneous(uint8_t* servos, uint8_t* targets, uint8_t count) {
  // ... initialise current[], limited[], done[] ...

  bool anyActive = true;
  while (anyActive) {
    anyActive = false;

    // Tick at the slowest rate among still-active servos
    uint8_t tickDelay = 0;
    for (uint8_t i = 0; i < count; i++) {
      if (!done[i] && STEP_DELAY[servos[i]] > tickDelay)
        tickDelay = STEP_DELAY[servos[i]];
    }

    for (uint8_t i = 0; i < count; i++) {
      if (done[i]) continue;
      uint8_t s = servos[i];
      if (current[i] < limited[i]) current[i] += STEP_SIZE[s];
      else                         current[i] -= STEP_SIZE[s];
      board1.setPWM(SERVO_CH[s], 0, angleToPulse((int)current[i]));
      if ((int)current[i] == (int)limited[i]) done[i] = true;
      else anyActive = true;
    }
    delay(tickDelay);  // dynamic — decreases as faster servos complete
  }
}

V2 also tightened some angle limits after continued physical testing (S2 narrowed to 60°–120° after binding observed; S3 expanded to 20°–160° after structure was adjusted) and moved the S6 Grip home to 30°.

Stage 2 Challenges

MG995 StictionCovered above — the core insight of Stage 2. Solved through iterative STEP_DELAY reduction until smooth gliding was confirmed. S1 counterintuitively needed a slightly higher delay (40 ms) because the heavier base rotation benefits from longer steps under load.
V1 MG995 Restriction — Was It Necessary?All three MG995s were commanded simultaneously to the same target from home while monitoring the supply. No brownout or reset. The 3A supply proved adequate. V2 written to remove the restriction. V1's IS_MG995 guard code remains a useful pattern for weaker power supplies.
Controller Migration (Uno → S3)I2C pin mapping, Wire.begin() signature, and library compatibility all changed. XIAO S3 SDA/SCL are GPIO6/GPIO7 rather than A4/A5. Wire.begin() called without arguments to use ESP32 Arduino core defaults. Adafruit PWMServoDriver confirmed compatible. Serial upgraded to 115200 baud.
What Stage 3 fixes: Motion is still fundamentally blocking — delay() lives inside the while loop. While a servo moves, the CPU is occupied. This is acceptable for serial-only control but completely unacceptable for a WiFi HTTP server, which must remain responsive at all times.

Stage 3 — WiFi Web Dashboard
Completed

Stage 3 is where Dum-E becomes a network device. The XIAO ESP32 S3 connects to WiFi, starts an HTTP server on port 80, and serves a complete control interface to any browser on the same network. No USB cable or Serial Monitor needed — any phone or laptop on the same WiFi can open the IP address and control all six joints.

Stage 3 also solves the fundamental blocking problem from Stage 2. The motion engine is fully rewritten to be non-blocking using millis()-based timing. Both the HTTP server and servo stepping run concurrently in the same loop() with neither blocking the other.

Wiring — PCA9685 → XIAO ESP32 S3

PCA9685 PinConnects ToNotes
SDAD4 (GPIO 6)I2C data
SCLD5 (GPIO 7)I2C clock
VCC3.3VLogic power from S3
GNDGNDCommon ground
V+5V external PSUServo power — separate from logic supply

Access the web UI at http://<IP printed to Serial Monitor on boot> from any device on the same WiFi network.

The Core Architectural Shift — Non-Blocking Motion

The entire blocking while loop from Stage 2 is gone. Replaced by a ServoState struct and a stepServos() function that advances one step per servo per loop() call — no delay() anywhere.

Stage3_WebUI.ino — ServoState struct
struct ServoState {
  float    current;   // actual position (float for sub-degree stepping)
  uint8_t  target;    // desired destination
  uint32_t lastStep;  // millis() timestamp of last step
};
ServoState sv[7];  // index 1–6
Stage3_WebUI.ino — stepServos()
// Called on every single loop() iteration — never blocks
void stepServos() {
  uint32_t now = millis();
  for (uint8_t s = 1; s <= 6; s++) {
    if ((int)sv[s].current == (int)sv[s].target) continue;  // already there
    if (now - sv[s].lastStep < STEP_DELAY[s])    continue;  // not time yet

    if (sv[s].current < sv[s].target) sv[s].current += STEP_SIZE[s];
    else                              sv[s].current -= STEP_SIZE[s];

    sv[s].current = constrain(sv[s].current,
                   SERVO_MIN_ANGLE[s], SERVO_MAX_ANGLE[s]);
    board1.setPWM(SERVO_CH[s], 0, angleToPulse((int)sv[s].current));
    sv[s].lastStep = now;
  }
}

// The entire main loop
void loop() {
  server.handleClient();  // handle incoming HTTP — returns immediately if nothing pending
  stepServos();           // advance any due servos — returns immediately
  // Neither ever blocks. HTTP server is always responsive, even during motion.
}

HTTP Server — 5 Routes

RouteParametersAction
GET /NoneServes full web UI HTML from PROGMEM
GET /move?s=N&a=As=servo, a=°Sets sv[s].target — returns immediately
GET /homeNoneSets all 6 targets to home angles
GET /stopNoneSets each target to current position (freeze)
GET /statusNoneReturns JSON of all current angles
Stage3_WebUI.ino — key route handlers
// setTarget() — the ONLY write path for target angles
void setTarget(uint8_t s, uint8_t angle) {
  if (s < 1 || s > 6) return;
  sv[s].target = constrain(angle, SERVO_MIN_ANGLE[s], SERVO_MAX_ANGLE[s]);
}

// /stop — freeze wherever the arm currently is
void handleStop() {
  for (uint8_t i = 1; i <= 6; i++)
    sv[i].target = (uint8_t)sv[i].current;  // target = current → stepServos() stops
  server.send(200, "text/plain", "OK");
}

// /status — returns ground-truth positions as JSON
void handleStatus() {
  String json = "{";
  for (uint8_t i = 1; i <= 6; i++) {
    json += "\"s" + String(i) + "\":" + String((int)sv[i].current);
    if (i < 6) json += ",";
  }
  json += "}";
  server.send(200, "application/json", json);
  // e.g.: {"s1":90,"s2":73,"s3":90,"s4":45,"s5":110,"s6":60}
}

Web UI Deep Dive

The entire web UI is a self-contained HTML/CSS/JS page stored in PROGMEM using Arduino's raw string literal syntax and served via server.send_P() — reads directly from flash without copying to SRAM, preserving heap for the HTTP and WiFi stacks.

Web UI — slider definitions
// Each servo's physical range — not a generic 0–180° slider
const SERVOS = [
  { id:1, name:'Waist',       min:0,   max:180, home:90  },
  { id:2, name:'Shoulder',    min:45,  max:135, home:90  },
  { id:3, name:'Elbow',       min:30,  max:150, home:90  },
  { id:4, name:'Wrist Roll',  min:0,   max:180, home:90  },
  { id:5, name:'Wrist Pitch', min:45,  max:135, home:110 },
  { id:6, name:'Grip',        min:20,  max:100, home:60  },
];
Web UI — slider throttle (50 ms per servo)
const lastSend = {};

function sendMove(servo, angle) {
  const now = Date.now();
  if (lastSend[servo] && now - lastSend[servo] < 50) return;  // 20 req/s max
  lastSend[servo] = now;
  fetch('/move?s=' + servo + '&a=' + angle).catch(() => {});
}

// Final send on slider release — guarantees last angle always reaches firmware
sl.addEventListener('change', () => sendMove(s.id, +this.value));
Web UI — status polling (500 ms sync)
// Keeps UI in sync regardless of which input moved the arm
setInterval(() => {
  fetch('/status')
    .then(r => r.json())
    .then(d => {
      for (let i = 1; i <= 6; i++) {
        const a = d['s' + i];
        if (a === undefined) continue;
        // Don't override a slider the user is actively dragging
        if (document.activeElement !== sliders[i]) {
          sliders[i].value = a;
          labels[i].innerHTML = a + '<span class="unit">°</span>';
        }
      }
    }).catch(() => {});
}, 500);

The 500 ms status poll is the key forward-looking design decision: when Stage 4's glove controller moves a servo via ESP-NOW without the browser knowing, the browser will still reflect the correct arm state automatically.

Stage 3 Challenges

HTTP Server Unresponsive During MotionAn early prototype kept the Stage 2 blocking motion engine. Browser requests during servo moves timed out completely. Rule established: nothing in loop() may call delay() or block for any significant time. Non-blocking engine confirmed working in isolation before HTTP server was added.
Slider FloodingContinuous slider drag fired hundreds of /move requests per second. ESP32 HTTP stack froze after a few seconds — running out of socket resources. The 50 ms per-servo throttle in sendMove() reduced this to a comfortable 20 req/s per servo. No server hangs in extended testing after this fix.
UI Desync With Multiple InputsEarly Stage 4 planning revealed the browser would show stale positions when the glove moved servos. The /status polling endpoint was designed and implemented in Stage 3 — before Stage 4 began — ensuring the web UI is a live monitor of ground truth, not just a reflection of its own commands.
Embedding HTML in FirmwareFull HTML/CSS/JS as a C++ string literal required careful handling of quotes and memory. Arduino's R"rawliteral(...)rawliteral" raw string eliminates all escaping. PROGMEM stores in flash rather than SRAM. server.send_P() reads from flash directly during HTTP response.
What Stage 4 adds: All control is still manual — sliders require deliberate user input. Stage 4 adds a wearable glove that maps physical hand gestures to servo joints over ESP-NOW. The /status poll and non-blocking motion engine built in Stage 3 already anticipate this — both were designed with dual-input in mind.

Stage 4 — ESP-NOW Glove Controller
In Progress

Stage 4 introduces a wearable glove controller as the second input modality. The glove uses an MPU6050 IMU for hand orientation (Pitch and Roll) and two resistive flex sensors for finger bend detection. All sensor data is packed into a struct and broadcast over ESP-NOW every loop cycle from the XIAO ESP32 C3 on the glove to the XIAO ESP32 S3 on the arm.

On the arm side, an ESP-NOW receive callback fires on every incoming packet, decodes the struct, and routes each field to the appropriate sv[s].target — feeding directly into the same stepServos() engine that the web UI already uses. Both control inputs share the same motion engine with no conflict.

Glove Hardware Stack

ComponentRoleNotes
XIAO ESP32 C3Glove MCU — ESP-NOW transmitterSingle-core, WiFi, compact form factor
MPU60506-axis IMU — hand orientationPitch + Roll only — Yaw excluded (see Challenges)
Flex Sensor 1 (A0)Grip control — always S6Resistive; 10 kΩ voltage divider
Flex Sensor 2 (A1)Mode toggle — ARM vs WRISTThreshold-based binary state
Stage4 — ADC configuration on C3
#define FLEX1_PIN  A0   // Flex 1 → S6 Grip (always)
#define FLEX2_PIN  A1   // Flex 2 → mode switch

analogReadResolution(12);        // 12-bit → 0 to 4095
analogSetAttenuation(ADC_11db);  // full 3.3V input range
// 12-bit gives ~0.8 mV/count — sufficient for flex bend detection

System Architecture

Glove Controller

XIAO ESP32 C3

  • MPU6050 → Pitch, Roll (comp. filter)
  • Flex 1 → S6 Grip control
  • Flex 2 → Mode Toggle (ARM/WRIST)
  • Pack glove_data struct

Browser Dashboard

Any Device (WiFi)

  • Per-servo sliders (correct range)
  • HOME / STOP buttons
  • 500 ms /status poll
  • Reflects glove moves automatically
ESP-NOW (~2 ms)
WiFi HTTP

Main Arm Controller

XIAO ESP32 S3

  • ESP-NOW RX → decode struct → setTarget()
  • HTTP server → slider /move → setTarget()
  • stepServos() → millis() tick → advance current
  • I2C → PCA9685 → PWM → Servos
I2C @ 0x40, 60 Hz

PCA9685 PWM Driver

16-channel — 60 Hz

  • CH0 → S1 Waist (MG995)
  • CH1 → S2 Shoulder (MG995)
  • CH2 → S3 Elbow (MG995)
  • CH3 → S4 Wrist Roll (MG90S)
  • CH4 → S5 Wrist Pitch (SG90)
  • CH5 → S6 Grip (SG90)

Both the ESP-NOW callback and the HTTP handler write to the same sv[].target array. Whichever wrote last determines where the arm moves. In practice the web UI handles Waist (S1) and monitoring; the glove controls the other joints.

Sensor-to-Servo Mapping

The mapping was designed on paper before a single line of transmitter or receiver firmware was written. Constraints: 1 IMU (Pitch + Roll), 2 flex sensors, 5 joints to control (S2–S6), S1 Waist reserved for web UI. A naive 1:1 mapping covers only 4 joints. Solution: repurpose Flex 2 as a binary mode toggle.

WRIST Mode

Flex 2 straight

  • IMU Pitch → S5 Wrist Pitch
  • IMU Roll → S4 Wrist Roll
  • Flex 1 → S6 Grip (always)

ARM Mode

Flex 2 bent

  • IMU Pitch → S2 Shoulder
  • IMU Roll → S3 Elbow
  • Flex 1 → S6 Grip (always)

S1 (Waist) is intentionally excluded from glove control. Waist rotation is a coarse, full-range sweep. Mapping it to IMU Roll in ARM mode would conflict with the Elbow mapping. Precise waist positioning benefits more from a slider than from tilt — easier to dial in a specific bearing with a slider than to hold the glove at a precise yaw.

Stage4_ReceiverIntegration.ino — ESP-NOW receive callback
void onGloveData(const uint8_t *mac, const uint8_t *data, int len) {
  glove_data packet;
  memcpy(&packet, data, sizeof(packet));

  bool armMode = (packet.flex2_value > FLEX2_THRESHOLD);

  if (armMode) {
    setTarget(2, mapAngle(packet.pitch, PITCH_MIN, PITCH_MAX, 45, 135));  // S2 Shoulder
    setTarget(3, mapAngle(packet.roll,  ROLL_MIN,  ROLL_MAX,  30, 150));  // S3 Elbow
  } else {
    setTarget(5, mapAngle(packet.pitch, PITCH_MIN, PITCH_MAX, 45, 135));  // S5 Wrist Pitch
    setTarget(4, mapAngle(packet.roll,  ROLL_MIN,  ROLL_MAX,   0, 180));  // S4 Wrist Roll
  }
  // Grip: always Flex 1, always S6
  setTarget(6, mapAngle(packet.flex1_value, FLEX1_STRAIGHT, FLEX1_BENT, 20, 100));
}

Why ESP-NOW

ESP-NOW was selected after direct experience with Bluetooth instability (HC-05/HC-06 connection drops under servo current load in a related college project).

PropertyESP-NOWBluetooth Serial (HC-05/HC-06)
Pairing requiredNo — peer MAC address onlyYes — pairing sequence required
Latency~1–3 ms10–30 ms typical
Router dependencyNoneNone (direct link)
Native ESP32 supportYes — esp_now.hNo — external UART module
ReliabilityConnectionless — no session to dropSession-based — can drop under interference

The S3's MAC address is read from Serial output once during initial setup and hardcoded into the C3 transmitter sketch. One-time configuration, not a runtime pairing process.

Flex Calibration — Deep Dive

Before any transmitter firmware can map flex sensor readings to servo angles, the ADC range of each sensor must be measured in the actual wired glove. Resistive flex sensors vary significantly between units — even the same nominal sensor from the same batch can have very different absolute resistance values depending on manufacturing tolerances. Hardcoding ADC values without per-unit calibration would produce wildly incorrect angle mappings.

The solution is a dedicated standalone calibration sketch that runs on the C3 before the transmitter firmware is ever flashed. It exposes an interactive Serial menu — the user types a letter, holds a pose, and the sketch records the averaged ADC reading for that position.

DumE_Stage4_FlexCalibration.ino — interactive menu
// Runs standalone on XIAO ESP32 C3 at 115200 baud
// ========================================
//   Dum-E — Flex Sensor Calibration
// ========================================
//   R  — Read raw ADC values (live)
//   1  — Calibrate Flex1 STRAIGHT
//   2  — Calibrate Flex1 BENT
//   3  — Calibrate Flex2 STRAIGHT
//   4  — Calibrate Flex2 BENT
//   T  — Test mapped angles live
//   S  — Show calibration values
//   H  — Show this menu
// ========================================

The ESP32's ADC is known to be noisy — single readings fluctuate by tens of counts even on a stable input. A single-sample calibration would lock in a noisy value. The fix is to average 20 samples with small gaps between each, producing a stable representative value.

DumE_Stage4_FlexCalibration.ino — ADC averaging
// 20-sample average with 2 ms gaps — reduces ESP32 ADC noise
#define NUM_SAMPLES 20

int readADC(uint8_t pin) {
  long sum = 0;
  for (int i = 0; i < NUM_SAMPLES; i++) {
    sum += analogRead(pin);
    delay(2);
  }
  return (int)(sum / NUM_SAMPLES);
}

Each calibration step follows the same pattern: print a prompt, wait 2 seconds for the user to settle into the correct pose (straight or fully bent), then take an averaged ADC reading. The 2-second pause eliminates motion transients from the sample — without it, the reading would capture the sensor mid-bend rather than at its true endpoint.

DumE_Stage4_FlexCalibration.ino — calibration step
// Each step: 2s wait for user to hold pose still, then averaged ADC read
case '1': {
  Serial.println(F("[CAL] Hold Flex1 finger STRAIGHT"));
  Serial.println(F("[CAL] Keep still... sampling in 2s"));
  delay(2000);  // user settles into pose; eliminates motion transients
  flex1_straight = readADC(FLEX1_PIN);
  Serial.print(F("[CAL] Flex1 STRAIGHT = "));
  Serial.println(flex1_straight);
  break;
}

Flex 2 is special — it's not used as a continuous control input but as a binary mode toggle. Rather than mapping its full ADC range to an angle, the sketch calculates a single threshold value: the midpoint between the straight and bent readings. A reading above this threshold means ARM Mode; below means WRIST Mode. Using the midpoint gives equal hysteresis distance in both directions, reducing the chance of accidental mode switches near the boundary.

DumE_Stage4_FlexCalibration.ino — Flex 2 threshold
// Mode toggle threshold: midpoint between straight and bent ADC values
case '4': {
  delay(2000);
  flex2_bent = readADC(FLEX2_PIN);
  flex2_calibrated = true;

  int threshold = (flex2_straight + flex2_bent) / 2;  // symmetric hysteresis
  Serial.print(F("[CAL] Mode switch threshold = "));
  Serial.println(threshold);
  break;
}

After all four readings are captured, command T streams live angle-mapped output to Serial at 5 Hz. This is the critical human verification step before the transmitter sketch is ever flashed — the user physically bends each finger and confirms the correct joint angle changes in the Serial output. They also flex Flex 2 to confirm the MODE label switches between ARM and WRIST. If mode doesn't switch, the threshold comparison direction may need to be inverted — noted in the code.

DumE_Stage4_FlexCalibration.ino — live test (command T)
// Human verification step before flashing transmitter sketch
case 'T': {
  int threshold = (flex2_straight + flex2_bent) / 2;
  while (!Serial.available()) {
    int f1 = readADC(FLEX1_PIN);
    int f2 = readADC(FLEX2_PIN);
    int s6angle = adcToAngle(f1, flex1_straight, flex1_bent, 20, 100);
    bool armMode = (f2 > threshold);
    Serial.print(F("  S6: "));  Serial.print(s6angle);
    Serial.print(F("°   Mode: ")); Serial.print(armMode ? F("ARM  ") : F("WRIST"));
    Serial.print(F("   [F1:")); Serial.print(f1);
    Serial.print(F(" F2:"));    Serial.print(f2);
    Serial.println(F("]"));
    delay(200);
  }
  break;
}

Once all four values are confirmed via the live test, command S prints the complete set of constants in a format ready to paste directly into the transmitter sketch header. The main transmitter firmware contains no hardcoded ADC values — it only references these defines. Any Dum-E build with different sensors just runs this calibration sketch once and pastes the output.

DumE_Stage4_FlexCalibration.ino — output (command S)
// Ready-to-paste #define constants for the transmitter sketch
case 'S': {
  int threshold = (flex2_straight + flex2_bent) / 2;
  Serial.println(F("  PASTE THESE INTO STAGE 4 CODE:"));
  Serial.print(F("  #define FLEX1_STRAIGHT  ")); Serial.println(flex1_straight);
  Serial.print(F("  #define FLEX1_BENT       ")); Serial.println(flex1_bent);
  Serial.print(F("  #define FLEX2_STRAIGHT  ")); Serial.println(flex2_straight);
  Serial.print(F("  #define FLEX2_BENT       ")); Serial.println(flex2_bent);
  Serial.print(F("  #define FLEX2_THRESHOLD  ")); Serial.println(threshold);
  // Example output:
  //   #define FLEX1_STRAIGHT  1240
  //   #define FLEX1_BENT       3180
  //   #define FLEX2_THRESHOLD  2100
  break;
}

The angle mapping function deserves attention: depending on how the flex sensor is physically wired in the glove, the ADC value might increase when the finger is straight and decrease when bent — or vice versa. A naive map() call would produce inverted angles if adcMin is greater than adcMax. The safe version handles both orientations automatically using min() and max() to compute the correct clamp bounds regardless of direction.

DumE_Stage4 — adcToAngle() safe mapping
// Handles the case where adcMin > adcMax (sensor wired in reverse orientation)
int adcToAngle(int raw, int adcMin, int adcMax, int angleMin, int angleMax) {
  raw = constrain(raw, min(adcMin, adcMax), max(adcMin, adcMax));
  return map(raw, adcMin, adcMax, angleMin, angleMax);
}

Firmware in Development

DumE_Stage4_Transmitter.ino — transmitter loop (in development)
// Complementary filter: gyro-dominant with slow accel drift correction
void computeComplementaryFilter(float *pitch, float *roll) {
  float dt = (millis() - lastFilterTime) / 1000.0;
  lastFilterTime = millis();
  float accelPitch = atan2(ay, az) * RAD_TO_DEG;
  float accelRoll  = atan2(ax, az) * RAD_TO_DEG;
  *pitch = 0.96 * (*pitch + gx * dt) + 0.04 * accelPitch;
  *roll  = 0.96 * (*roll  + gy * dt) + 0.04 * accelRoll;
  // α=0.96 → τ≈0.24s: fast enough to prevent drift, slow enough for stability
}

void loop() {
  computeComplementaryFilter(&glovePacket.pitch, &glovePacket.roll);
  glovePacket.flex1_value = readADC(FLEX1_PIN);
  glovePacket.flex2_value = readADC(FLEX2_PIN);
  esp_now_send(s3_mac_address, (uint8_t*)&glovePacket, sizeof(glovePacket));
  // glove_data packet: 2 floats + 2 ints = 16 bytes. ESP-NOW max: 250 bytes.
}

Stage 4 Challenges & Design Decisions

Yaw Excluded — Gyroscope Drift Makes It UnusableInitial design included yaw mapped to S1 Waist. After evaluating MPU6050 data, gyroscope drift caused yaw to accumulate at several degrees per second with no correction (no magnetometer for absolute heading). After 30 seconds, yaw could be off by 20–30°. Resolution: yaw removed entirely before any yaw-based firmware was written. Pitch and Roll are stable because the accelerometer's gravity vector provides an absolute reference for the complementary filter to correct against.
Insufficient ADC Range on Flex SensorsAn early wiring test showed Flex 1 with less than 100 ADC counts of difference between straight and fully bent (on a 0–4095 12-bit scale). A delta under 100 almost always means a wiring fault — dry solder joint, wrong resistor value, or reversed connections — not a bad sensor. Resolution: if delta < 100 counts, substitute 47 kΩ pull-down resistor. Higher resistance amplifies voltage swing for sensors with smaller resistance change.
Dual-Mode Mapping With Limited Sensors1 IMU + 2 flex sensors needs to control 5 joints. A naive 1:1 mapping leaves one joint unreachable. Resolution: Flex 2 repurposed as binary mode toggle rather than a continuous third axis. Binary switching is cognitively simple, the midpoint threshold provides symmetric hysteresis, and the calibration sketch's T command makes it easy to confirm the comparison direction before flashing.
Complementary Filter Alpha — Pending Physical Tuningα = 0.96 is a standard starting value (τ ≈ 0.24 s at ~100 Hz loop rate). The actual optimal alpha depends on the C3 loop rate under full sensor load (MPU6050 I2C + ADC reads + ESP-NOW transmission overhead) and the vibration characteristics of the glove under arm motion. Will be confirmed empirically during Stage 4 integration testing.

Current Progress

Glove hardware assembled (C3 + MPU6050 + flex sensors)
Flex calibration sketch written, tested, documented
Sensor-to-servo mapping fully designed
Mode toggle logic designed
ESP-NOW data struct defined
S3 receiver callback architecture designed
ESP-NOW transmitter firmware (C3)
S3 receiver integration (Stage 3 + ESP-NOW)
Complementary filter tuning under physical load
Full glove + arm integration test

Results & Status

Stages 1–3 are fully confirmed working. Stage 4 hardware is assembled and calibration tooling is complete; transmitter firmware is in active development.

Full simultaneous servo motion (all 6 axes)Confirmed
Non-blocking firmware (millis() engine)Confirmed
WiFi web dashboard with slider controlConfirmed
Live status readback (500 ms polling)Confirmed
Per-servo physically tuned parametersConfirmed
Safe startup snap sequenceConfirmed
Flex calibration tooling completeConfirmed
Sensor-to-servo mapping fully designedConfirmed
ESP-NOW transmitter firmware (C3)In development
ESP-NOW receiver integration on S3Planned
Complementary filter tuning under loadPlanned
Full glove + arm integration testPlanned

What Full Stage 4 Completion Delivers

  • WRIST Mode: physical wrist tilt controls S4 (Wrist Roll) and S5 (Wrist Pitch) in real time
  • ARM Mode: tilting the forearm forward/back controls S2 (Shoulder); side tilt controls S3 (Elbow)
  • Grip: curling Flex 1 opens and closes S6 continuously
  • Mode switch: bending Flex 2 switches all mappings in under one packet cycle (~2 ms)
  • Simultaneous web UI: browser continues to reflect all positions via 500 ms polling even as glove controls them
  • Web UI retains S1 (Waist): slider control unaffected by glove input

What I Learned

  • Adding a web server to a blocking motion loop kills the server completely — requests just time out while a servo is moving. I had to rebuild the entire motion engine around millis() to fix it, which was more work mid-project than it would have been to design it that way from the start.

  • The arm doesn't know where it is — only the firmware's currentAngle[] does. If those two ever diverge (which they will on cold boot if you don't handle it), the first command causes a violent snap to wherever the arm thinks it should be. The startup snap sequence exists entirely to prevent that.

  • Once you physically validate PWM calibration constants, don't touch them again. SERVOMIN and SERVOMAX have been the same since Stage 1. Recalibrating is how you introduce regressions, not improvements.

  • The stiction problem on MG995 servos surprised me most. I expected smoother to mean slower — but it's the opposite. Update PWM too infrequently and the motor fully settles between steps, then has to break static friction each time. It lurches. Update frequently enough and it never settles — it just flows. Finding that sweet spot per-servo through physical testing was the most hands-on debugging I did.

  • Checking sensor drift on the MPU6050 before writing any control code saved a lot of time. Yaw drifts badly without a magnetometer — I caught that early and removed it from the design before a single line of yaw-based firmware was written.

  • The complementary filter handles Pitch and Roll better than I expected for something so simple. I was going to use a Kalman filter, but the complementary filter is stable enough for human-speed arm control and takes about ten lines of code.

  • When a flex sensor shows less than 100 ADC counts between straight and fully bent, it's almost never the sensor — it's the wiring. Bad solder joint, wrong resistor value, something like that. I learned to check the circuit first before assuming the sensor is broken.

  • Drawing out the sensor-to-servo mapping on paper before writing any transmitter code made the firmware straightforward. The two-mode glove design came from constraints on paper, not from debugging firmware.

  • ESP-NOW is genuinely good. No pairing, no router, ~2 ms round trip. Bluetooth had been unreliable in a previous project under servo load, so I switched — and it just works.

  • Finishing each stage completely before starting the next one saved me a lot of debugging. When something broke in Stage 3, I knew it was a Stage 3 problem. The search space was small.

  • Datasheets don't capture mechanical reality. STEP_DELAY values, joint limits, home positions — all of them came from looking at and listening to the arm, not from any calculation.

  • Cutting yaw cleanly was the right call. The decision took five minutes. I didn't try to work around it or replace it with something equivalent. Knowing when to just remove scope is a skill.