Pico W Web Servo Controller is a runnable microcontroller simulation example on SensBench — open the wired circuit, inspect the code, and compile real firmware in your browser, no install needed. Last updated Aug 14, 2026.

FREE SIMULATOR EXAMPLE · ADVANCED

Pico W Web Servo Controller

This example controls a servo from a web page served by the Raspberry Pi Pico W — the browser posts angles, the firmware maps them to PWM pulse widths. The web-remote servo pattern.

Board Raspberry Pi Pico W (RP2040 + WiFi)
Difficulty advanced
Category robotics
Libraries None — just the Arduino core
Runs in Sensbench browser simulator — free, no account
Raspberry Pi Pico W controlling a servo from a web page in the Sensbench simulator

The exact circuit you get when you open this example — already wired, ready to run.

The sketch

# main.py
import network
import socket
from machine import Pin, PWM
import time

# Import credentials from the separate secrets file
ssid = "kritish"
password = "password"

# ====== WiFi Setup ======
print("Connecting to WiFi...")
sta = network.WLAN(network.STA_IF)
sta.active(True)

# Attempt to connect to the network
try:
    sta.connect(ssid, password)
except Exception as e:
    print(f"Connection error: {e}")

timeout_counter = 0
while not sta.isconnected() and timeout_counter < 20: # Timeout after ~10 seconds
    time.sleep(0.5)
    print(".", end="")
    timeout_counter += 1

if sta.isconnected():
    print("\nConnected!")
    print("IP address:", sta.ifconfig()[0])
else:
    print("\nConnection failed! Check credentials and signal.")
    raise OSError("WiFi connection failed.")


# ====== Servo Setup (Pico W Specific) ======
SERVO_PIN = 15 # Ensure the servo is connected to GP15.
servo = PWM(Pin(SERVO_PIN), freq=50)

SERVO_FREQ = 50 
SERVO_MIN_US = 500   # 0 degrees (0.5ms pulse width)
SERVO_MAX_US = 2500  # 180 degrees (2.5ms pulse width)
TOTAL_PERIOD_US = 1000000 / SERVO_FREQ # 20000 us (20ms)
DUTY_SCALE = 65535 # Max value for Pico's duty_u16 method

def write_servo(angle):
    """Sets the servo angle (0-180 degrees) by calculating the PWM duty_u16."""
    angle = max(0, min(180, angle)) 
    pulse_us = SERVO_MIN_US + (SERVO_MAX_US - SERVO_MIN_US) * (angle / 180)
    duty = int((pulse_us / TOTAL_PERIOD_US) * DUTY_SCALE)
    servo.duty_u16(duty)


# ====== HTML Page (Vibrant Redesign) ======
def webpage(pos):
    """Generates the HTML page with a vibrant design and current servo position."""
    html = f"""
    <!DOCTYPE html>
    <html>
    <head>
    <title>Pico W Servo</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700&display=swap" rel="stylesheet">
    <style>
        :root {{
            --primary-color: #6a0dad; /* Deep Purple */
            --secondary-color: #ff6f61; /* Coral Red */
            --accent-color: #ffda03; /* Bright Yellow */
            --bg-light: #f0f8ff; /* Alice Blue */
            --text-dark: #2c3e50; /* Dark Grey Blue */
        }}

        body {{
            margin: 0;
            font-family: 'Poppins', sans-serif;
            background: linear-gradient(45deg, var(--bg-light) 0%, var(--primary-color) 100%);
            color: var(--text-dark);
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            overflow: hidden;
        }}

        .container {{
            background: rgba(255, 255, 255, 0.9);
            border-radius: 15px;
            padding: 40px;
            box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
            text-align: center;
            max-width: 400px;
            width: 90%;
            backdrop-filter: blur(5px);
            -webkit-backdrop-filter: blur(5px);
        }}

        h1 {{
            color: var(--primary-color);
            margin-bottom: 10px;
            font-size: 2em;
            font-weight: 700;
        }}

        p {{
            font-size: 1.1em;
            margin-bottom: 25px;
            color: var(--text-dark);
        }}

        strong#servoVal {{
            font-size: 1.8em;
            color: var(--secondary-color);
            display: block;
            margin-top: 10px;
            font-weight: 600;
        }}

        .slider {{
            -webkit-appearance: none;
            width: 100%;
            height: 15px;
            background: linear-gradient(to right, var(--accent-color), var(--secondary-color));
            border-radius: 8px;
            outline: none;
            opacity: 0.8;
            transition: opacity .2s ease-in-out;
            margin-top: 20px;
            margin-bottom: 30px;
        }}

        .slider:hover {{
            opacity: 1;
        }}

        /* Slider Thumb Styling */
        .slider::-webkit-slider-thumb {{
            -webkit-appearance: none;
            appearance: none;
            width: 25px;
            height: 25px;
            border-radius: 50%;
            background: var(--primary-color);
            cursor: grab;
            box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
            border: 2px solid var(--bg-light);
        }}

        .slider::-moz-range-thumb {{
            width: 25px;
            height: 25px;
            border-radius: 50%;
            background: var(--primary-color);
            cursor: grab;
            box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
            border: 2px solid var(--bg-light);
        }}

        .info-text {{
            font-size: 0.9em;
            color: #7f8c8d; /* Grey */
            margin-top: 20px;
        }}
    </style>
    </head>
    <body>
        <div class="container">
            <h1>Servo Control</h1>
            <p>Current Angle:</p>
            <strong id="servoVal">{pos}&deg;</strong>

            <input type="range" min="0" max="180" value="{pos}" class="slider"
            id="servoSlider" oninput="update(this.value)"
            onchange="send(this.value)">

            <p class="info-text">Move and release the slider to update the servo position.</p>
        </div>

        <script>
            function update(val) {{
                document.getElementById("servoVal").innerHTML = val + '&deg;';
            }}

            function send(val) {{
                fetch('/?value=' + val)
                    .then(response => {{
                        if (response.ok) {{
                            console.log("Command sent successfully:", val);
                        }} else {{
                            console.error("Failed to send command:", response.status);
                        }}
                    }})
                    .catch(error => {{
                        console.error("Network error:", error);
                    }});
            }}
        </script>

    </body>
    </html>
    """
    return html


# ====== Web Server ======
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('', 80))
s.listen(5)

current_pos = 90
write_servo(current_pos)

print("Web server started on port 80...")

while True:
    try:
        conn, addr = s.accept()
        print("Client connected from:", addr)

        request = conn.recv(1024).decode('utf-8')
        request_line = request.split('\n')[0] 
        print("Request line:", request_line)

        if "GET /?value=" in request_line:
            try:
                value_str = request_line.split("/?value=")[1].split(" ")[0]
                new_pos = int(value_str)

                if 0 <= new_pos <= 180:
                    current_pos = new_pos
                    write_servo(current_pos)
                    print(f"Servo angle updated to: {current_pos}°")
                else:
                    print(f"Error: Angle {new_pos} is out of the 0-180° range.")
            
            except (ValueError, IndexError):
                print("Error: Invalid angle value or malformed URL in request.")
        
        response = webpage(current_pos)

        conn.send('HTTP/1.1 200 OK\r\n')
        conn.send('Content-Type: text/html\r\n')
        conn.send('Connection: close\r\n\r\n')
        conn.sendall(response.encode('utf-8'))
        conn.close()
        
    except OSError as e:
        print(f"Socket error: {e}")
        conn.close()
    except Exception as e:
        print(f"An unexpected error occurred in main loop: {e}")

How it works

The firmware serves the control page and listens for position updates. Each update maps the requested angle to a pulse width and writes it to the PWM channel on the servo pin. The servo tracks the browser’s control with one HTTP round trip of latency.

The angle-to-pulse mapping is the servo’s language: roughly 1 ms for 0 degrees, 1.5 ms for 90, 2 ms for 180, with the exact endpoints tuned per servo. The page’s UI is a thin wrapper — the interesting code is the PWM translation.

Before you build it for real

The servo’s power must come from a separate supply — the Pico W’s 3V3 rail cannot feed the motor, and drawing servo current from the board browns out the WiFi chip mid-move. The pulse-width endpoints vary between servos — the 1-2 ms range is nominal, and each physical unit needs its extremes measured and tuned. And the HTTP round trip is the control latency: fast enough for a slider, too slow for anything that needs sub-100 ms response.

Get the parts

Related examples

FAQ

How does the web page reach the servo?

The Pico W serves an HTML page with a control — a slider or buttons — and each interaction posts the value back over HTTP. The firmware maps the value to a PWM duty on the servo pin. Browser to pulse width in one round trip.

Why PWM duty_ns for servo control?

Servo position lives in nanosecond-scale pulse widths: 1 ms to 2 ms encodes 0 to 180 degrees. MicroPython’s duty_ns() sets the pulse width directly, which is far clearer than a 0-65535 duty value that then needs translation.