Build Project

Robotics

ESP32 WiFi Servo Pulse Controller

Send bounded 1400–1600 µs commands to a documented FS90 positional servo from a local browser, with explicit enable, external power and timeout limits.

IntermediateParent Safe
ESP32 WiFi Servo Pulse Controller logical illustration, not a photograph or verified physical pinout
Jump to a section

What You Will Build

A local web page with Enable at 1500 us, a narrow pulse-width slider and Disable pulse output. The example controls only a small unloaded servo. It does not report measured shaft position or claim a universal angle conversion. This is distinct from the existing joystick-controlled arm and standalone SG90 component.

Before You Start

Classic ESP32-WROOM DevKit, Arduino IDE 2.x and Arduino-ESP32 3.3.2 as the API review baseline, not the latest-release claim. Select ESP32 Dev Module only for the stated generic WROOM board. Other ESP32 families need their own GPIO and LEDC review. Complete the Serial-only IDE upload first. Documentation/code-structure and local browser checks do not establish compilation or physical operation; these sketches have not been compiled or hardware-tested. Exact FEETECH FS90 positional unit supplied/documented as Pololu #2818, with linked FS90-C specification and verified 3.3 V control compatibility. The supplier documents 3.3 V and 5 V signals; this does not identify your own servo. FS90R is continuous rotation and excluded. The manufacturer linked specification gives a 4.8–6 V operating range; the build selects regulated 5 V and limits commands to 1400–1600 µs around 1500 µs.

Parts

  • Classic ESP32-WROOM DevKitLEDC 50 Hz, 16-bit review baseline 3.3.2.
  • FEETECH FS90 positional servoExact Pololu #2818 / linked FS90-C specification; NOT FS90R, SG90 or unidentified clone.
  • Separate regulated 5 V supplyAt least 1 A bench budget for this one unloaded servo; verify actual unit peaks and supply response.
  • 10 kΩ resistor and suitable connectionsControl-line pull-down; size servo power leads for current, not thin GPIO jumpers.

Wiring

Identify signal, positive and ground from the exact documented servo connector; wire color is only a clue. GPIO25 drives signal directly only because the selected supplier explicitly supports 3.3 V input. Fit 10 kΩ from signal to GND for a defined reset-time LOW. The servo positive connects only to external regulated 5 V. Join external minus, servo ground and ESP32 GND; do not join external positive to USB/3V3. Provide local supply decoupling according to the actual supply/lead design. This logical diagram is not a connector-orientation drawing.

ESP32 WiFi Servo Pulse Controller logical signal and power map; not physical pin order

Open wiring diagram full size

  1. 1

    With USB and servo supply disconnected, identify connector functions and remove the horn/load.

  2. 2

    Wire signal25 with a 10 kΩ pull-down; wire servo power to the separate supply and share ground. Check polarity before powering.

  3. 3

    Upload/reset ESP32 over USB with servo power off; confirm pulse output starts disabled.

  4. 4

    Turn on the verified servo supply, open the local page, clear the area and press Enable at 1500 us. Test only small changes.

  5. 5

    Disable pulses before turning off external power. Disconnect both supplies before changing wiring.

Wiring Reference

SignalESP32 PinDirectionNotes
FS90 signalGPIO25Output10 kΩ pull-down from signal to GND
FS90 positiveExternal regulated 5 VPowerSeparate supply sized for startup/stall
FS90 groundSupply minus + ESP32 GNDGroundShare ground, never positive rails
ESP32 powerUSB data cablePowerDo not power the servo from DevKit rails

Libraries and Setup

  • Built-in WiFi and WebServer from Arduino-ESP32 3.3.2; LEDC is built into core 3.3.2. No ESP32Servo library is used or implied.

Configure the Example

Change CHANGE_ME_1234 to your own 8–63 character AP password. Upload and open Serial at 115200. Join the printed ESP32 network, stay connected if your phone reports no internet, and open the printed http:// address directly. The classic board uses a 2.4 GHz AP on channel 6, allows one connected station and does not join your router. No internet, external JavaScript, cloud account or third-party Arduino library is required. The AP name is ESP32-WebServo. Enable starts at 1500 µs; Apply pulse accepts only 1400–1600 µs. The slider steps by 10 µs. These are command units, not calibrated degrees. Accepted enable/move commands renew a 15-second lifetime; page refresh does not. Press Enable again after expiry. Disconnect external power if any movement, buzzing or heating is unexpected.

Control and Recovery Behavior

ConditionResult
Startup / AP failureSignal is LOW and no PWM is attached. The external supply is a separate manual control.
POST /armNo arguments accepted; attaches 50 Hz/16-bit LEDC and commands 1500 µs. This can move the servo immediately.
POST /pulseOne integer us argument in 1400–1600; 400 for invalid data, 409 if not enabled and 503 on driver failure.
POST /off or idle expiryAttempts zero duty, detaches LEDC and drives signal LOW. Motor supply remains powered; torque behavior is not guaranteed.

Code

Install the libraries above and match the configuration and wiring before uploading.

web-servo.ino
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>

WebServer server(80);
const char* AP_PASSWORD = "CHANGE_ME_1234"; // Replace with your own 8-63 character password.
bool httpReady = false;

void redirectHome() {
  server.sendHeader("Location", "/");
  server.send(303, "text/plain", "Open the control page\n");
}
String pageStart(const char* title) {
  String page = "<!doctype html><html lang='en'><head>";
  page += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
  page += "<style>body{font:1.1rem system-ui;max-width:40rem;margin:2rem auto;padding:1rem}button,input{font:inherit;margin:.5rem 0}input[type=range]{width:100%}</style>";
  page += "<title>"; page += title; page += "</title></head><body><h1>";
  page += title; page += "</h1>";
  return page;
}
void sendPage(const String& page) {
  server.sendHeader("Cache-Control", "no-store");
  server.send(200, "text/html; charset=utf-8", page + "</body></html>");
}
bool parseUnsigned(const String& text, uint16_t minimum, uint16_t maximum, uint16_t& value) {
  if (text.length() == 0 || text.length() > 4) return false;
  uint32_t n = 0;
  for (size_t i = 0; i < text.length(); ++i) {
    char c = text[i];
    if (c < '0' || c > '9') return false;
    n = n * 10 + (c - '0');
  }
  if (n < minimum || n > maximum) return false;
  value = uint16_t(n); return true;
}
void startHTTP(const char* name) {
  server.onNotFound([]() { server.send(404, "text/plain", "No such route\n"); });
  if (!WiFi.mode(WIFI_AP) || !WiFi.softAP(name, AP_PASSWORD, 6, false, 1)) {
    Serial.println("AP failed; output stays off. Check board and password length.");
    return;
  }
  server.begin(); httpReady = true;
  Serial.print("Join "); Serial.println(name);
  Serial.print("Open http://"); Serial.println(WiFi.softAPIP());
}

constexpr uint8_t SIGNAL_PIN = 25, RESOLUTION = 16;
constexpr uint16_t MIN_US = 1400, CENTER_US = 1500, MAX_US = 1600;
constexpr uint32_t FRAME_US = 20000, COMMAND_LIFETIME_MS = 15000;
bool enabled = false;
uint16_t lastPulseUs = CENTER_US;
uint32_t lastCommand = 0;

uint32_t pulseTicks(uint16_t us) {
  return (uint32_t(us) * (1UL << RESOLUTION) + FRAME_US / 2) / FRAME_US;
}
void disablePulses() {
  if (enabled) {
    bool zeroOK = ledcWrite(SIGNAL_PIN, 0);
    bool detached = ledcDetach(SIGNAL_PIN);
    if (!zeroOK || !detached) Serial.println("LEDC stop reported an error; remove external power");
  }
  enabled = false;
  pinMode(SIGNAL_PIN, OUTPUT); digitalWrite(SIGNAL_PIN, LOW);
  Serial.println("Pulse output disabled; servo supply is NOT switched off");
}
bool writePulse(uint16_t us) {
  if (!enabled || !ledcWrite(SIGNAL_PIN, pulseTicks(us))) { disablePulses(); return false; }
  lastPulseUs = us; lastCommand = millis(); return true;
}
void showPage() {
  String page = pageStart("ESP32 bounded servo control");
  page += String("<p>Pulse output: ") + (enabled ? "enabled" : "disabled") + ".</p>";
  page += "<p>Last accepted pulse: " + String(lastPulseUs) + " us; this is not measured position.</p>";
  page += "<form method='post' action='/arm'><button>Enable at 1500 us</button></form>";
  page += "<form method='post' action='/pulse'><label for='pulse'>Pulse width 1400 to 1600 us</label>";
  page += "<input id='pulse' name='us' type='range' min='1400' max='1600' step='10' value='";
  page += String(lastPulseUs) + "'><button>Apply pulse</button></form>";
  page += "<form method='post' action='/off'><button>Disable pulse output</button></form>";
  page += "<p>Enable can cause immediate movement. Commands expire after 15 seconds; disabling pulses is NOT a certified stop or power isolation.</p>";
  sendPage(page);
}
void setup() {
  Serial.begin(115200);
  pinMode(SIGNAL_PIN, OUTPUT); digitalWrite(SIGNAL_PIN, LOW);
  server.on("/", HTTP_GET, showPage);
  server.on("/arm", HTTP_POST, []() {
    if (server.args() != 0) { server.send(400, "text/plain", "No arguments expected\n"); return; }
    if (!enabled) enabled = ledcAttach(SIGNAL_PIN, 50, RESOLUTION);
    if (!enabled || !writePulse(CENTER_US)) {
      disablePulses(); server.send(503, "text/plain", "Pulse driver unavailable\n"); return;
    }
    Serial.println("Pulse output enabled at 1500 us; keep clear of movement");
    redirectHome();
  });
  server.on("/pulse", HTTP_POST, []() {
    uint16_t next = 0;
    if (server.args() != 1 || !server.hasArg("us") ||
        !parseUnsigned(server.arg("us"), MIN_US, MAX_US, next)) {
      server.send(400, "text/plain", "Exactly one integer us, 1400 through 1600, is required\n"); return;
    }
    if (!enabled) { server.send(409, "text/plain", "Enable at center before moving\n"); return; }
    if (!writePulse(next)) { server.send(503, "text/plain", "Pulse write failed\n"); return; }
    Serial.printf("Accepted pulse %u us; no position feedback\n", unsigned(next));
    redirectHome();
  });
  server.on("/off", HTTP_POST, []() {
    if (server.args() != 0) { server.send(400, "text/plain", "No arguments expected\n"); return; }
    disablePulses(); redirectHome();
  });
  startHTTP("ESP32-WebServo");
}
void loop() {
  if (httpReady) server.handleClient();
  if (enabled && millis() - lastCommand >= COMMAND_LIFETIME_MS) {
    Serial.println("Command expired"); disablePulses();
  }
  delay(1);
}

How the Code Works

At 50 Hz the frame is 20,000 µs. The code rounds pulse_us × 65,536 / 20,000 into the 16-bit timer duty. It uses core 3.x ledcAttach/ledcWrite with a GPIO pin; no 500–2500 µs endpoint sweep or assumed 0–180° mapping is used. The last accepted pulse remains displayed when output is disabled, explicitly labelled as historical command data. No potentiometer/shaft feedback is connected. Synchronous request servicing can delay the software expiry.

Expected Output

On a verified bench setup the page first says disabled. Enable should start center-region pulses; small changes should produce limited movement on the selected positional servo. Disable or expiry should stop command pulses, but does not verify actual stopped motion or released torque. These are intended checks, not an observed hardware demonstration.

Troubleshooting

  • Cannot open the page Confirm the phone is still on the ESP32 AP, use the Serial-printed http:// address and check mobile-data/VPN routing. The AP deliberately provides no internet.
  • HTTP 400 from a command Use the provided form. Exact argument counts and allowed values are checked; blank, negative, decimal or nonnumeric values are not converted silently.
  • Page shows an old value Refresh the page. It reports the last accepted software command, not measured light output or shaft position. GET/refresh does not renew the command lifetime.
  • Servo spins continuously Stop external power and verify the exact model. An FS90R or converted continuous-rotation unit is not the supported FS90 positional servo.
  • ESP32 resets or servo chatters Stop the test. Check the separate supply, its transient-current capability, common ground, lead resistance and actual servo loading. A capacitor does not replace an adequate supply.
  • Enabled page but no movement Check power and connector identity; a web/LEDC success does not prove a physical signal or servo response. Confirm the selected unit really accepts 3.3 V control.
  • Buzzing, heating or blocked motion Remove external power immediately; remove the load and inspect mechanics. Do not increase pulse endpoints or hold a stall to force motion.
  • Pulse output expired while page stayed open Only accepted enable/move commands renew the 15-second window. Refresh the page for software state; no heartbeat or continuous command stream is implemented.

Checks to Perform on Your Build

  • Verify model/connector/supply and keep a reachable physical power switch before enabling.
  • Observe small unloaded moves near 1500 µs; do not infer exact angles.
  • Try Apply before Enable and expect 409; invalid pulse requests should return 400.
  • Check explicit Disable, idle expiry and reset behavior using appropriate bench instruments if available.
  • Observe that external supply stays on after Disable; never use it as an emergency-stop substitute.

Practical Design Notes

The FS90-C manufacturer sheet lists stall current at its specified supply test points; the chosen ≥1 A regulated bench supply is a design budget, not an invented universal servo rating. Confirm the actual revision, current limits and lead behavior. Travel and endpoint limits depend on the exact revision and mechanics; this example deliberately makes no universal travel-angle claim. Generic SG90 modules require their own voltage, pulse and input-threshold evidence; the existing SG90 page uses a qualified buffer design and is not interchangeable with this wiring. This is plain HTTP on a trusted local demonstration AP. The Wi-Fi password is network admission, not per-user HTTP authentication. Anyone admitted can issue commands; POST alone does not prevent CSRF, and the sketch has no TLS, CSRF token, rate limiting or user roles. Do not expose the port through a router or adapt this example to locks, heaters, mains equipment or safety-critical mechanisms. The synchronous WebServer can wait on slow clients; the loop timeout is best-effort cleanup, not a hard real-time or certified safety stop.

Review, Testing, and References

Author: Abdul Mubeen and the ESP32 Engine editorial team. Last updated: 2026-10-24. Reviewed: wiring logic, Arduino code structure, beginner safety, and learning sequence.

Educational level: Intermediate. Estimated completion time: Varies by build. This project is for learning and prototyping; production or unattended hardware needs additional engineering review.