6.14. LED dimming with PWM

The Generating analog with PWM and an RC filter page used an RC low-pass filter to extract a DC voltage from a PWM signal. For an LED, the filter is not needed – the human eye itself does the averaging.

When an LED is switched on and off faster than about 60 Hz, the visual system stops resolving individual pulses and perceives a steady brightness equal to the average light output. A 50 % duty cycle reads as roughly half brightness; 25 % as a quarter; 10 % as dim.

The wiring is the same as for a static external LED on GPIO output – a current-limiting resistor in series with the LED, sized using the rules from Electronics basics. The change is only in software: the pin runs as a PWM output instead of a plain Pin.OUT.

6.14.1. Picking the frequency

For LED dimming the PWM frequency only has to clear the eye’s flicker threshold:

  • Below ~60 Hz the eye sees the pulses outright.

  • Below ~200 Hz peripheral vision and rapid eye motion can still reveal flicker.

  • 1 kHz is comfortably above all of that and is a typical default.

There is no upper bound that matters for a small LED on a GPIO; anything from 1 kHz to 10 kHz behaves the same to the eye.

6.14.2. Fading

A fade-in / fade-out loop sweeps the duty cycle from off to fully on and back, dwelling briefly at each step:

import time
from machine import PWM, Pin

led = PWM(Pin("P7"), freq=1000, duty_u16=0)

while True:
    for d in range(0, 65535, 256):
        led.duty_u16(d)
        time.sleep_ms(5)
    for d in range(65535, 0, -256):
        led.duty_u16(d)
        time.sleep_ms(5)

At 1 kHz PWM and 5 ms steps the eye sees a smooth fade in both directions, with the apparent brightness tracking the duty value.

Perceived brightness is not strictly linear in duty cycle – the eye’s response follows roughly a square or cube law – so a linear sweep of duty_u16 does not look like a linear sweep of brightness. For a perceptually smoother fade, step the duty on a curve.

A convenient integer-only trick is to step an 8-bit counter and use its square as the duty cycle. 255 × 255 = 65025 is within rounding of full scale, so the sweep covers the whole range:

import time
from machine import PWM, Pin

led = PWM(Pin("P7"), freq=1000, duty_u16=0)

while True:
    for step in range(256):
        led.duty_u16(step * step)   # 0..65025, roughly quadratic
        time.sleep_ms(5)
    for step in range(255, -1, -1):
        led.duty_u16(step * step)
        time.sleep_ms(5)

The fade now feels roughly even in apparent brightness from off to full.