devicescript-pwm-servos-dimmer

PWM, Servos, and LED Dimming in DeviceScript

  • 5 min

PWM (Pulse Width Modulation) is a technique that regulates the average power of a digital signal by varying how long it stays active.

We want to set an LED to 50% brightness, or move a robotic arm to exactly 45 degrees. The problem is that microcontrollers are digital; they only understand 0V and 3.3V.

To solve this, we use PWM (Pulse Width Modulation), a technique that involves turning a digital signal on and off so fast that it appears analog.

In Arduino, this meant configuring analogWrite(), worrying about which pins have the squiggle (~), and, in the case of servos, importing external libraries. In DeviceScript, true to its philosophy, all of this is abstracted into Services.

Dimming an LED

Actually, we’ve already used PWM. In the previous article, we wrote:

await led.intensity.write(0.5)
Copied!

Under the hood, the LightBulb driver automatically configured the PWM hardware of the ESP32 (the LEDC peripheral) or the Pico W.

That’s the beauty of DeviceScript: You don’t need to know the frequency the LED oscillates at. You request 50% light, and the service handles generating the appropriate electrical signal.

Do not connect motors, relays, or other power loads directly to a GPIO pin. They need an appropriate power stage, protection against inductive loads, and, depending on the case, a specific service or driver.

Controlling a Servo Motor

This is where things get interesting. A servo motor is a motor with a brain and a gearbox that can position itself at a specific angle (usually between -90º and 90º, or 0º and 180º).

Servos are controlled with a very specific PWM signal (pulses from 1ms to 2ms every 20ms). Generating this “by hand” is a pain. DeviceScript offers us the Servo service.

Connecting the Servo

A servo has 3 wires:

  1. Brown/Black: GND (Ground).
  2. Red: VCC (usually 5V).
  3. Orange/Yellow: Signal (to the microcontroller pin).

Power Warning: Servos draw a lot of current when starting up. If you connect a small servo (like an SG90) to the 5V pin of your board, it might work, but if you use a large one or several at once, you need an external power supply. Otherwise, your microcontroller will constantly reset (Brownout).

If you use an external source, connect its GND to the microcontroller’s GND so they share the same signal reference.

The Servo Service

Let’s move a servo connected to GPIO pin 10.

import { delay } from "@devicescript/core"
import { startServo } from "@devicescript/servers"
import { pins } from "@dsboard/esp32_c3_devkit_m"

// Start the Servo service
const miServo = startServo({
    pin: pins.GPIO10,
    // Optional: Pulse adjustments if your servo is special
    // minAngle: -90, maxAngle: 90 
})

// Perform a sweep
async function sweep() {
  while (true) {
    // Move to -90 degrees (minimum)
    console.log("Moving to -90º")
    await miServo.angle.write(-90)
    await delay(1000)

    // Move to 0 degrees (center)
    console.log("Moving to 0º")
    await miServo.angle.write(0)
    await delay(1000)

    // Move to 90 degrees (maximum)
    console.log("Moving to 90º")
    await miServo.angle.write(90)
    await delay(1000)
  }
}

sweep()
Copied!

When you open the simulator, a servo control will appear. The virtual arm will rotate to the positions indicated by the program.

Enabling and Disabling

Servos exert force to maintain their position. This consumes power and can heat up the motor.

The Servo service has an enabled property.

  • enabled = true: The servo exerts force to maintain the angle.
  • enabled = false: The servo “relaxes” and you can move it by hand.
// Turn off the servo to save power
await miServo.enabled.write(false)
Copied!

Controlling the Servo with a Potentiometer

Let’s combine everything we’ve learned. We’ll control the servo’s position with a potentiometer. This is the basis of any teleoperated robotic arm.

We have a mathematical problem:

  • The Potentiometer gives us values from 0 to 1.
  • The Servo expects angles from -90 to 90.

We need a mapping function (the classic rule of three).

import { startServo, startPotentiometer } from "@devicescript/servers"
import { pins } from "@dsboard/esp32_c3_devkit_m"

const pot = startPotentiometer({ pin: pins.GPIO4 })
const servo = startServo({ pin: pins.GPIO10 })

// Helper function for linear mapping
function map(x: number, in_min: number, in_max: number, out_min: number, out_max: number) {
    return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min;
}

// Subscribe to potentiometer changes
pot.reading.subscribe(async (potValue) => {
    // potValue comes from 0 to 1
    // Transform it to -90 to 90
    const angle = map(potValue, 0, 1, -90, 90)
    
    // Move the servo
    await servo.angle.write(angle)
})
Copied!

Testing it in the Simulator

This is one of the most fun examples to try on the Dashboard:

Start the simulator.

Locate the potentiometer slider and the servo arm.

Drag the Slider with the mouse.

Check how the servo follows the potentiometer value.

No wires, no risk of burning anything, and with immediate response.

Other Uses of PWM

DeviceScript applies the same logic for everything:

  • Buzzers: Use PWM to generate tones. They have their own Buzzer service where you send playTone(frequency, duration).
  • DC Motors: Use the Motor service. You specify the speed from -1 to 1, and the driver manages the PWM and direction (if you use a compatible H-bridge driver).