Skip to main content

3.2 Distance Fade LED

The goal of this example is to control the brightness of an LED from the distance measured by the ultrasonic sensor. The closer an object comes to the sensor, the brighter the LED glows.

That is a bigger step than it sounds. Until now a pin has been either on or off, and an LED either lit or dark. Here the pin has to produce everything in between, and the distance has to be turned into a brightness level. Two tools do that work: PWM and a map function you write yourself.

In this documentation you will learn:

  • What PWM is and how it makes an LED look dimmer than fully on.
  • How duty_u16() sets a brightness, and what its range is.
  • How to write your own map function, because MicroPython does not come with one.
  • How clamping keeps a value inside a range you can use.
  • What happens when you reverse an output range.

Hardware required:

  • 1x Soldered NULA MINI board
  • 1x Breadboard
  • 1x Ultrasonic distance sensor (HC-SR04)
  • 1x LED (any color)
  • 1x 330 ohm resistor
  • 1x Qwiic cable
  • 3x Jumper wires
  • 1x USB-C cable
ℹ️
This build is 3.1 with an LED added. If you still have that one wired up, leave the Qwiic cable where it is and start at step 2.

Putting the components together

1. Insert the NULA MINI board on the breadboard

Push the board into one end of the breadboard so that its two rows of pins sit on either side of the centre channel, with the chip facing down.

NULA MINI board seated on the breadboard, occupying rows 25 to 30
Step 1: the board seated on the breadboard, with IO2 in row 25 and GND in row 30

2. Bring IO2 out to a free row

IO2 is the pin that will drive the LED, and the board covers all of row 25 except the outermost hole. So the first wire simply carries that pin somewhere you can work: a jumper from j25 up to j15.

Jumper wire running along column j from row 25 to row 15
Step 2: IO2 brought out from j25 to j15

3. Add the 330 Ω resistor

The resistor is the next link in the chain. Place it so it bridges row 15, the row the jumper just arrived in, and row 13, both on the f–j side.

330 ohm resistor bridging rows 15 and 13 on the f to j side
Step 3: the 330 Ω resistor bridging rows 15 and 13
ℹ️
Resistors have no polarity, so it does not matter which way round it goes. Its legs are long and easy to bend, which is why they arch across the surface rather than running straight. That is fine, as long as each end is pushed firmly into its hole.

4. Add the LED

The LED continues the chain: its long leg (anode) into row 13, the same row the resistor ends in, and its short leg (cathode) into row 11.

LED added, with one leg in row 13 alongside the resistor and the other in row 11
Step 4: the LED in place, long leg toward the resistor
⚠️
Get the LED the right way round. An LED only passes current in one direction. The long leg must face the resistor and IO2; the short leg must face ground. Backwards, it simply will not light. Nothing will break, but nothing will happen either. If you cannot tell the legs apart by length, look for the flat notch on the rim of the plastic body: the leg on that side is the short one.

5. Ground the LED and the board

Two jumpers finish the circuit, and both go to the blue rail, which acts as a shared ground line:

  • From row 11, where the short leg of the LED sits, across to the blue rail.
  • From j30 (GND) across to that same rail.
Two black jumper wires, one from row 11 and one from j30, both going to the negative rail
Step 5: the LED and the GND pin of the board both tied to the blue − rail
ℹ️
Both wires have to go into the same rail column, the one running alongside the blue line. The rail next to the pink line is a separate strip and is not connected to it.

The full path is now IO2 → 330 Ω → LED → − rail → GND. This is the same chain as 1.2 LED Blinking, built on a different pin. The difference is entirely in the code: instead of switching the pin on and off, we will hold it somewhere in between.

ℹ️
Rows 15, 13 and 11 are only the rows these photos happen to use. Any free rows work, as long as the order of the chain holds: the jumper from IO2, then the resistor, then the LED, then ground.

6. Plug in the sensor and the USB-C cable

The sensor needs no wiring at all. It is a Qwiic module, so one cable from the qwiic connector of the board, the white one between the USER and RST buttons, to either connector on the sensor carries power and data together. If you have just come from 3.1 it is already plugged in.

The finished build: LED circuit on the breadboard, Qwiic cable running to the ultrasonic sensor, USB-C connected
Step 6: the finished build, with the LED circuit on the breadboard and the sensor on one Qwiic cable
ℹ️
Everything about the sensor side of this example is exactly as it was in 3.1: the same single cable, the same 0x30 address, the same ADDR sliders left alone, and the same requirement that the two silver transducers face out into clear space. Because it talks over I2C on IO6 and IO7, which are not brought out to the pin headers, it never competes with the LED for a pin.

Understanding PWM and brightness control

A digital pin has only two states, so it cannot produce half a voltage. What it can do is switch between the two very quickly, and spend a different amount of time in each. That is PWM, Pulse Width Modulation.

If the pin is on for half of every cycle, the LED receives half the energy it would get if the pin were on all the time, and your eye, far too slow to see the switching, reads that as half brightness. The fraction of each cycle spent switched on is called the duty cycle.

Duty cycleVisual effect
0%LED completely OFF
50%LED at half brightness
100%LED fully ON
PWM waveforms showing a low duty cycle beside a high duty cycle
Two duty cycles: the pin spends longer switched on in the second, so the average is higher and the LED looks brighter

In MicroPython the LED is driven by a PWM object rather than a plain Pin, and it takes two settings:

led = PWM(Pin(LED_PIN))
led.freq(5000)

freq() sets how fast the pin switches on and off, 5000 times per second here, far quicker than your eyes can follow, so you see a steady brightness instead of flickering. duty_u16() then sets the brightness itself, as a number from 0 (fully off) to 65535 (fully on).

ℹ️
65535 is where the Arduino and MicroPython versions of this example differ. An Arduino sketch has to call analogWriteResolution(LED_PIN, 12) first and then works in the range 0–4095. MicroPython always uses the full 16-bit range with duty_u16(), so there is no resolution to set and nothing to forget. The brightness numbers in the console are correspondingly larger.
⚠️
Do not confuse duty_u16() with the same call in 2.4 Buzzer Beep. On a buzzer, 65535 means the pin never switches and the buzzer is silent. On an LED, 65535 means the pin is on all the time and the LED is at full brightness. Same function, and the difference is entirely in what is connected to the pin.

Turning a distance into a brightness

Three things have to happen between reading the sensor and writing to the pin.

First, discarding non-readings. A distance of 0 means the sensor heard no usable echo, so there is nothing to work with. The script says so and jumps straight to the next pass:

if distance == 0:
print("No echo received, nothing in range.")
continue
ℹ️
continue abandons the rest of this pass through the loop and goes back to the top. Without it a zero would be treated as an object pressed right against the sensor, and the LED would flash to full brightness every time the room went quiet.

Second, clamping. The script only cares about the range from 2 cm to 50 cm, and forces any reading outside that range to the nearest edge:

if distance < MIN_DISTANCE:
distance = MIN_DISTANCE
if distance > MAX_DISTANCE:
distance = MAX_DISTANCE

Without this, an object three metres away would produce a brightness far below zero, a meaningless value to write to a pin. Clamping guarantees the next step gets a number inside a range it was designed for.

Third, rescaling. Arduino has a built-in map() function for this. MicroPython does not, so the script defines its own:

def value_map(value, in_min, in_max, out_min, out_max):
return int((value - in_min) * (out_max - out_min) / (in_max - in_min) + out_min)
ℹ️
def is how you define a function in Python. The name is followed by the values it expects in brackets, and return hands the answer back to whoever called it. Writing it once at the top of the file means the loop below can just use it, and the arithmetic only exists in one place.
ℹ️
The int() around the whole expression matters. Dividing in Python produces a decimal number even when both sides divide evenly, and duty_u16() will not accept a decimal. Without int() the script fails with TypeError: can't convert float to int.

It is then called with the output range deliberately backwards:

brightness = value_map(distance, MIN_DISTANCE, MAX_DISTANCE, MAX_BRIGHTNESS, 0)

Read that as: distance runs from 2 cm to 50 cm; brightness runs from 65535 down to 0. A small distance lands at the high end of the brightness range and a large one at the low end, which is exactly the effect we want: closer means brighter.

Working through a few values by hand shows what the script will print:

DistanceBrightnessWhy
2 cm or less65535clamped to the minimum, so fully on
33 cm23210about a third of the way up the range
39 cm15018dim but clearly lit
45 cm6826only just visible
50 cm or more0clamped to the maximum, so fully off
ℹ️
The brightness does not fall evenly to the eye. Halving the number does not halve how bright the LED looks, because our perception of brightness is not linear. The low end of the range covers a lot more visible change than the high end, which is why the LED seems to snap on as your hand comes inside about 45 cm and then changes more gently after that.

Code

# The Soldered driver for the Ultrasonic Distance Sensor, found in the lib folder of the examples repository.
from UltrasonicSensor import UltrasonicSensor

# I2C is what the Qwiic connector carries, and the sensor is a Qwiic module.
# PWM stands for Pulse Width Modulation. It switches a pin on and off very quickly, and the longer it stays on
# during each cycle, the brighter an LED connected to it looks to our eyes.
from machine import I2C, Pin, PWM
import time

# This is a variable to which we pass the number of pin that we had connected the LED to. Because we want to dim
# this LED and not only switch it on and off, we drive it with PWM rather than with a plain Pin.
#
# Remember that the LED needs a 330 Ohm resistor in series with it. That resistor limits how much current flows,
# and without it the LED draws more than either it or the pin is built for, so both can be damaged.
LED_PIN = 2

# These two variables define the distance range we care about. Anything closer than the minimum counts as "as close
# as possible" and anything further than the maximum counts as "far away". Feel free to experiment with these.
MIN_DISTANCE = 2
MAX_DISTANCE = 50

# duty_u16() sets the brightness as a number from 0 (fully off) to 65535 (fully on), so 65535 is the largest
# brightness we can ask for.
MAX_BRIGHTNESS = 65535

# This is how long we wait after asking for a measurement, in milliseconds.
MEASURE_WAIT_MS = 50

# Here we set up the I2C connection. On the NULA MINI, I2C uses IO6 for the data line (SDA) and IO7 for the clock
# line (SCL), which are exactly the pins the Qwiic connector is wired to.
i2c = I2C(0, scl=Pin(7), sda=Pin(6))

# Here we create our sensor object and hand it the I2C connection. We pass no pin numbers for the sensor itself:
# every Qwiic module shares those same two I2C pins, and this sensor answers on address 0x30.
sensor = UltrasonicSensor(i2c)

# Here we create our PWM object for the LED. freq() sets how fast the pin switches on and off. 5000 times per
# second is far quicker than our eyes can follow, so we see a steady brightness instead of flickering.
led = PWM(Pin(LED_PIN))
led.freq(5000)


def value_map(value, in_min, in_max, out_min, out_max):
# This is a function we wrote ourselves, because there is no ready-made function for rescaling a number.
# It takes a number from one range and rescales it into another range. Because we will pass the output range
# reversed, the smallest distance produces the largest brightness.
return int((value - in_min) * (out_max - out_min) / (in_max - in_min) + out_min)


# begin() prepares the sensor for use, before we start reading from it.
sensor.begin()

# Print out the initial message so we know that the program started successfully.
print("Distance Fade LED Example started!")

while True:

# takeMeasure() asks the sensor to send out a pulse and time the echo, and the wait afterwards gives it the
# moment it needs to finish that work and store the answer.
sensor.takeMeasure()
time.sleep_ms(MEASURE_WAIT_MS)

# getDistance() then fetches the stored answer, already converted into centimeters.
distance = sensor.getDistance()

# The sensor answers with 0 when it heard no echo at all. That is not a real measurement, so we skip the rest
# of this pass instead of treating it as an object pressed right up against the sensor.
# continue jumps straight back to the top of the loop, ready for the next measurement.
if distance == 0:
print("No echo received, nothing in range.")
continue

# Here we keep the measured distance inside the range we defined above. This is called clamping, and we do it
# because the next step expects a value inside a known range.
if distance < MIN_DISTANCE:
distance = MIN_DISTANCE
if distance > MAX_DISTANCE:
distance = MAX_DISTANCE

# Here we turn the distance into a brightness with our own function above. Notice that the output range is
# reversed, from MAX_BRIGHTNESS down to 0: the smallest distance gives the largest brightness.
brightness = value_map(distance, MIN_DISTANCE, MAX_DISTANCE, MAX_BRIGHTNESS, 0)

# duty_u16() writes the brightness to the pin, and the LED changes at once.
led.duty_u16(brightness)

# Print both values, so we can see how the brightness changes together with the distance.
print("Distance: {} cm -> Brightness: {}".format(distance, brightness))

What you should see

Press Run and hold your hand a few centimetres in front of the sensor. The LED comes up to full brightness:

The LED glowing at full brightness while a hand is held close in front of the ultrasonic sensor
A hand held close to the sensor, and the LED at the top of its range

Now take your hand slowly away and the glow fades, then goes out altogether. Every line in the Shell carries both halves of the story: what the sensor found, and what the LED was told to do about it:

Distance Fade LED Example started!
Distance: 2 cm -> Brightness: 65535
Distance: 33 cm -> Brightness: 23210
Distance: 39 cm -> Brightness: 15018
Distance: 45 cm -> Brightness: 6826
Distance: 50 cm -> Brightness: 0
Distance: 50 cm -> Brightness: 0

Three things are worth noticing in that output.

Any reading of 50 cm or more says Brightness: 0. Every one of them clamps to MAX_DISTANCE, and 50 cm maps to zero. So the LED stays dark for 52 cm, 65 cm, 90 cm and everything beyond. It only wakes up once your hand is genuinely close. If you see nothing but zeros, you are not far from a working circuit; you are just too far from the sensor.

The printed distance never leaves the 2 cm to 50 cm range. That is the clamping at work: the script overwrites distance with the clamped value before printing it, so a hand a metre away still shows as 50. The raw reading is not shown anywhere.

Readings around 1000 cm also give Brightness: 0. As described in 3.1, those are the way the sensor reports finding nothing at all rather than real distances, but clamping turns them into 50 cm anyway, so an empty room ends up looking exactly like "far away" and the LED stays off. That is the behaviour you want here, and it happens for free.

ℹ️
There is no pause at the end of the loop. The only wait is the 50 ms for the sensor, so readings arrive about twenty times a second and the Shell scrolls quickly. That is what makes the fade feel responsive rather than steppy. Nothing is wrong.
⚠️
If the numbers look right but the LED never lights, the wiring is at fault, not the code. Check three things in order: the long leg of the LED faces the resistor and its short leg faces ground; the resistor and the LED really do meet in the same row, so the chain is unbroken; and both ground wires sit in the rail column beside the blue line.

Try it yourself: change MAX_DISTANCE from 50 to 150 and run it again. The LED now responds from a metre and a half away, but every centimetre of movement changes the brightness less, so the fade is gentler and harder to see. Drop it to 20 instead and the opposite happens: nothing until your hand is very close, then a fast, obvious sweep from dark to full. Nothing about the circuit changed. You only redefined what "close" means.


Full example

Check out the full example code on the link below:

3.2_Distance_Fade_LED.py

Example that reads the Qwiic ultrasonic distance sensor and uses the distance to set the brightness of an LED with PWM.