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
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.

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.

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.

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.

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.

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.
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.

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 cycle | Visual effect |
|---|---|
| 0% | LED completely OFF |
| 50% | LED at half brightness |
| 100% | LED fully ON |

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).
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.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.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:
| Distance | Brightness | Why |
|---|---|---|
| 2 cm or less | 65535 | clamped to the minimum, so fully on |
| 33 cm | 23210 | about a third of the way up the range |
| 39 cm | 15018 | dim but clearly lit |
| 45 cm | 6826 | only just visible |
| 50 cm or more | 0 | clamped to the maximum, so fully off |
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:

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.
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.