7.4 RGB LED Controller
This project makes an RGB LED change colour with the light in the room. In darkness it glows red; as the light rises the colour slides through green and blue, and in genuinely bright light it turns white.
It brings together two things you have already built. The photoresistor circuit is exactly the one from 2.3 Photoresistor Analog Read, and the idea of setting a brightness instead of just switching a pin on and off comes from 3.2 Distance Fade LED. What is new is that one sensor reading now drives three outputs at the same time.
In this documentation you will learn:
- How to find the four legs of an RGB LED and which one is shared.
- How one analog reading can be turned into three separate brightnesses.
- How PWM mixes red, green and blue into any colour in between.
- How to convert the familiar 0–255 colour numbers into what
duty_u16()wants. - Why the colour fades smoothly instead of jumping.
Hardware required:
- 1x Soldered NULA MINI board
- 1x Breadboard
- 1x Photoresistor (LDR)
- 1x RGB LED
- 1x 10kΩ resistor
- 3x 330 Ω resistors
- 8x Jumper wires
- 1x USB-C cable
Putting the components together
Follow the twelve steps below. Each photo is taken from the same position, so you can compare it with the previous one and see exactly what changed.
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. It should occupy rows 25 to 30.

2. Bring ground out to the rail
One jumper from j30 (GND) across to the blue − rail. That rail becomes the shared ground for the whole circuit, and both halves connect back to it.

3. Bring 3.3 V out to row 14
A second jumper, from j29 (3V3) up to row 14. This is the top of the measuring circuit.

4. Add the photoresistor
The photoresistor bridges row 14, where 3.3 V now arrives, and row 11.

5. Add the 10kΩ resistor
The resistor continues the chain downwards, from row 11 to row 9.

6. Connect the resistor to ground
One jumper from row 9 across to the blue − rail.

The chain is now complete: 3.3 V → photoresistor → 10kΩ → GND. Current flows through both parts in series.
7. Tap the middle with IO5
Now the measurement itself. Run a jumper from j28 (IO5) to row 11, the row shared by the lower leg of the photoresistor and the upper lead of the resistor.

IO5 measures. If the wire lands on row 14 or row 9 instead, you will still get a reading. It simply will not change when the light does.8. Add the RGB LED
An RGB LED is three LEDs in one package sharing a single leg. Hold it with the legs pointing down and find the longest one. That is the shared leg, the common cathode. On this LED it sits third along, with one leg on one side of it and two on the other.
Seat the LED so its four legs land in rows 1, 2, 3 and 4, with the long leg in row 3:
| Leg | Row |
|---|---|
| Red, outermost, furthest from the long leg | 1 |
| Green, between Red and the long leg | 2 |
| Common cathode, the longest leg | 3 |
| Blue, alone on the other side of the long leg | 4 |

9. Ground the common cathode
One jumper from row 3 to the blue − rail. All three colour channels return to ground through this single wire.

10. Add the three 330 Ω resistors
Each colour channel needs its own current limiter. Bridge each of the three colour legs out to a free row further along the board:
- from row 1 (Red) to a free row
- from row 2 (Green) to another free row
- from row 4 (Blue) to a third free row

11. Wire the three colour channels to the board
Three more jumpers, from the far end of each resistor back to the matching pin. Use three different colours of wire, because they run side by side and you will want to tell them apart later.
| Channel | From | To |
|---|---|---|
| Red | the free row of the Red resistor | j25 (IO2) |
| Green | the free row of the Green resistor | j26 (IO3) |
| Blue | the free row of the Blue resistor | j27 (IO4) |

GND, 3V3, IO5, IO4, IO3, IO2) rather than guessing from the middle.12. Connect the board to your computer

How one sensor becomes three colours
Reading the light
The board cannot measure resistance, only voltage. A photoresistor changes its resistance with light, so on its own it gives the board nothing to read. Pairing it with the fixed 10kΩ makes a voltage divider, and the voltage at the point between the two depends on the ratio of their resistances:
- In bright light the resistance of the photoresistor drops, it keeps less of the 3.3 V for itself, and the reading at
IO5rises. - In darkness its resistance climbs, it keeps more of the voltage, and the reading falls.
ldr.read() turns that voltage into a number from 0 to 4095, because the NULA MINI has a 12-bit ADC.
Setting the colour
Each channel is a PWM object, created with its switching speed given up front:
pwm_r = PWM(Pin(RED_PIN), freq=PWM_FREQ)
pwm_g = PWM(Pin(GREEN_PIN), freq=PWM_FREQ)
pwm_b = PWM(Pin(BLUE_PIN), freq=PWM_FREQ)
Instead of only switching a pin fully on or fully off, PWM switches it on and off very quickly, 1000 times a second here, and the longer the pin stays on during each cycle, the brighter that channel looks. Writing all three at once is what mixes a colour.
The 0–255 problem, and the function that solves it
Everyone thinks about colour in the range 0 to 255: 255, 0, 0 is red, 0, 255, 0 is green, 255, 255, 255 is white. That is what every paint program and every web page uses.
But duty_u16() wants a number from 0 to 65535. Rather than scatter that conversion through the colour code, the script does it once in a function of its own:
def write_colour(pwm, brightness):
pwm.duty_u16(int(brightness * 65535 / 255))
analogWrite(RED_PIN, r) and works in 0–255 natively, so it needs no conversion at all. MicroPython works in 0–65535, and write_colour() keeps that fact in one place so the colour logic below can stay in the familiar range.int() is doing necessary work again. brightness * 65535 / 255 produces a decimal number in Python, and duty_u16() refuses one. Without int() the script fails with TypeError.Joining them up
The light range is split into three equal parts. FIRST_THIRD is 1365 and SECOND_THIRD is 2730, simply 4095 divided into thirds, and each part gets its own transition:
LDR value | What the code does | Result |
|---|---|---|
| 0 → 1365 | red fades down while green fades up | red → green |
| 1366 → 2730 | green fades down while blue fades up | green → blue |
| 2731 → 4095 | blue stays full while red and green rise | blue → white |
In every part, one channel is mapped upwards while another is mapped downwards. That is what makes one colour slide into the next instead of jumping. And because each part begins exactly where the previous one ended, the boundaries are invisible: at 1365 the colour is pure green whichever side you approach it from.
The rescaling itself uses the same value_map() function you wrote in 3.2, because MicroPython still has no built-in map().
Code
# ADC measures the actual voltage on a pin and gives us a number for it, which is how we read the light level. PWM
# switches a pin on and off very quickly, which is how we set a brightness instead of only on or off.
from machine import Pin, ADC, PWM
import time
# This is a variable to which we pass the number of pin that we had connected the output of the photoresistor to.
# Because we need to read a whole range of values here and not only high or low, this has to be a pin that supports
# analog input.
#
# This example also needs a 10k resistor. A photoresistor changes its resistance with light, but the board can only
# measure a voltage, so we pair the two in what is called a voltage divider.
LDR_PIN = 5
# These are the variables to which we pass the numbers of pins that we had connected the three colour channels of
# the RGB LED to. An RGB LED is really three LEDs in one package, one red, one green and one blue, and by lighting
# them at different strengths we can mix any colour we like.
#
# Remember that each of the three colour channels needs its own 330 Ohm resistor in series with it. An RGB LED counts
# as three LEDs, so it takes three resistors.
RED_PIN = 2
GREEN_PIN = 3
BLUE_PIN = 4
# This is how fast the PWM pins switch on and off. 1000 times per second is far quicker than our eyes can follow, so
# we see steady colours instead of flickering.
PWM_FREQ = 1000
# These two variables split the light range into three equal parts, which is what gives us our three colour
# transitions. The photoresistor readings run from 0 to 4095, and 4095 divided by three is 1365.
FIRST_THIRD = 1365
SECOND_THIRD = 2730
# Here we create our ADC object, which we named "ldr". The atten setting chooses how large a voltage the converter
# can measure, and ADC.ATTN_11DB is the widest setting, which lets us use the full range of the photoresistor.
ldr = ADC(Pin(LDR_PIN), atten=ADC.ATTN_11DB)
# Here we create one PWM object for each colour channel, all at the same switching speed.
pwm_r = PWM(Pin(RED_PIN), freq=PWM_FREQ)
pwm_g = PWM(Pin(GREEN_PIN), freq=PWM_FREQ)
pwm_b = PWM(Pin(BLUE_PIN), freq=PWM_FREQ)
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, which is how we turn a light level into a
# brightness.
return int((value - in_min) * (out_max - out_min) / (in_max - in_min) + out_min)
def write_colour(pwm, brightness):
# This function sends one colour channel to its pin.
# We think about colours as numbers from 0 to 255, the way almost every program does, but duty_u16() wants a
# number from 0 to 65535. This function does that conversion in one place, so the colour code below stays easy
# to read.
pwm.duty_u16(int(brightness * 65535 / 255))
# Print out the initial message so we know that the program started successfully.
print("RGB LED Controller with full-spectrum color mapping started")
while True:
# read() reads the voltage at the analog pin and converts it into a number. Since the NULA board uses a 12-bit
# ADC, the value runs from 0 in complete darkness to 4095 in bright light. The more light falls on the
# photoresistor, the lower its resistance and the higher this number becomes.
ldr_value = ldr.read()
print("LDR value:", ldr_value)
# This is where the colour is decided. We split the light range into three parts and give each one its own
# transition, so that the colour never jumps: it always slides from wherever it was into the next colour.
# Notice how in each part one channel is being mapped upwards while another is mapped downwards.
if ldr_value <= FIRST_THIRD:
# Darkest third: fade from red (255, 0, 0) to green (0, 255, 0).
r = value_map(ldr_value, 0, FIRST_THIRD, 255, 0)
g = value_map(ldr_value, 0, FIRST_THIRD, 0, 255)
b = 0
elif ldr_value <= SECOND_THIRD:
# Middle third: fade from green (0, 255, 0) to blue (0, 0, 255).
r = 0
g = value_map(ldr_value, FIRST_THIRD + 1, SECOND_THIRD, 255, 0)
b = value_map(ldr_value, FIRST_THIRD + 1, SECOND_THIRD, 0, 255)
else:
# Brightest third: fade from blue (0, 0, 255) to white (255, 255, 255). White is simply all three channels on
# at once, which is why red and green rise here while blue stays at full brightness.
r = value_map(ldr_value, SECOND_THIRD + 1, 4095, 0, 255)
g = value_map(ldr_value, SECOND_THIRD + 1, 4095, 0, 255)
b = 255
# Here we write all three channels at once, which is what mixes the colour.
write_colour(pwm_r, r)
write_colour(pwm_g, g)
write_colour(pwm_b, b)
# Print the mixed colour too, so we can compare it against the light level above.
print("RGB:", r, g, b)
# A short pause between readings. Keeping it small makes the colour changes look smooth.
time.sleep_ms(100)
What you should see
Press Run. The startup message appears, and then two lines arrive ten times a second: the raw light reading, and the three brightnesses the code worked out from it.
In an ordinary lit room the reading sits a little under a third of the way up the range, in the first band, so the LED settles on a green with a hint of red in it.

RGB LED Controller with full-spectrum color mapping started
LDR value: 1057
RGB: 57 197 0
LDR value: 1058
RGB: 57 197 0
Now put a hand over the photoresistor. The reading falls to somewhere around a hundred, which is almost the bottom of the first band, and the colour swings across to red.
LDR value: 106
RGB: 235 19 0
Take the hand away and hold a phone torch against the sensor instead. The reading climbs into the middle band and the colour goes to blue.

LDR value: 2465
RGB: 0 49 205
Those readings are worth comparing, because you can check the arithmetic of the code by hand. At 1057 the reading is in the first band, so red is mapped down from 255 and green up from 0: 255 − 1057 × 255 ÷ 1365 is 57.5, and int() throws the fraction away to give the 57 it printed. The number on the screen and the colour in front of you are the same fact, twice.
int() always cuts towards zero, it never rounds. 57.5 becomes 57, not 58. This is worth knowing if you compare these numbers with the Arduino version of the same project, which prints 58 here: Arduino's map() does the whole calculation in whole numbers and drops the fraction at a different point, so the two platforms can disagree by one on any channel that is being faded down. One count out of 255 is far too small to see.The whole range
| What you do | Roughly what you will read | RGB | Colour |
|---|---|---|---|
| Seal your palm over it | ~90–150 | 232, 22, 0 | red |
| Shade it with a hand, loosely | ~260–430 | 190, 64, 0 | orange-red |
| Leave it in a lit room | ~1000–1070 | 61, 193, 0 | green |
| Shine a phone torch on it | ~2300–2530 | 0, 58, 196 | blue |
IO5 really is tapping row 11.If the colours come out wrong
If the LED lights but shows the wrong colour (green where you expect red, or one channel that never comes on), the circuit is fine and the mapping is not. Work through it in this order:
- Check the long leg is in row 3 and wired to the − rail. If the shared leg is somewhere else, the channels fight each other and mostly stay dark.
- Check each resistor spans two rows. A shorted one makes its channel far brighter than the other two, which reads as that colour taking over.
- Swap the jumpers at the board end. If red and blue are exchanged, swap the wires in
j25andj27. The console tells you what colour the board intended: if it printsRGB: 255 0 0and you see blue, the two channels are crossed. - A pale, washed-out colour that barely moves points at the
attensetting, not at the LED. WithADC.ATTN_0DBin place ofADC.ATTN_11DBthe same room light reads close to 3900 instead of about 1050, which parks the code in its top band and leaves the LED near white whatever you do.
That last one is the useful habit: the RGB line is the board's own statement of what it is trying to display, so any disagreement between it and the LED in front of you is a wiring fault, not a code fault.
Full example
Check out the full example code on the link below:
7.4_RGB_LED_Controller.py
Project that uses a photoresistor to control an RGB LED, smoothly changing colour from red to white based on ambient light intensity.