Skip to main content

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
ℹ️
This example uses two different kinds of resistor, doing two completely different jobs. The 10kΩ is not protecting anything. It is half of the measuring circuit, exactly as in 2.3. The three 330 Ω are current limiters, one per colour channel. An RGB LED is three LEDs in one package, so it needs three of them.
⚠️
Check the bands before you plug anything in. 10kΩ reads brown-black-orange and 330 Ω reads orange-orange-brown. They are easy to mix up, and swapping them will not damage anything but will stop the circuit working.

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.

ℹ️
Everything in this example lives on the f–j side of the breadboard, because every pin it needs is on that edge of the board: IO2 in row 25, IO3 in row 26, IO4 in row 27, IO5 in row 28, 3V3 in row 29 and GND in row 30. The board covers most of those rows, so the outermost holes (j25 through j30) are the ones you can reach.

1. Insert the NULA MINI board on the breadboard

ℹ️
This step assumes you know how a breadboard is wired inside and what its power rails are. For an introduction, see Breadboard Fundamentals documentation page.

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.

NULA MINI board seated on the breadboard
Step 1: the board seated on the breadboard

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.

Jumper from j30 to the blue negative rail
Step 2: GND brought out to the blue − rail

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.

Jumper from j29 to row 14
Step 3: 3V3 brought out to row 14

4. Add the photoresistor

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

Photoresistor bridging rows 14 and 11
Step 4: the photoresistor bridging rows 14 and 11
ℹ️
A photoresistor has no polarity: either leg can go either way round. It is just a resistor whose value changes with light.

5. Add the 10kΩ resistor

The resistor continues the chain downwards, from row 11 to row 9.

10k resistor bridging rows 11 and 9
Step 5: the 10kΩ resistor bridging rows 11 and 9

6. Connect the resistor to ground

One jumper from row 9 across to the blue rail.

Jumper connecting row 9 to the negative rail
Step 6: the bottom of the 10kΩ resistor wired to the − 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 connected to the junction between the photoresistor and the resistor
Step 7: IO5 tapping the junction at row 11
ℹ️
Row 11 is the whole point of this half of the circuit. It is the midpoint between the two resistances, and its voltage moves as the light changes. That is what 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:

LegRow
Red, outermost, furthest from the long leg1
Green, between Red and the long leg2
Common cathode, the longest leg3
Blue, alone on the other side of the long leg4
RGB LED seated with its four legs in rows 1 to 4
Step 8: the RGB LED with its four legs in rows 1, 2, 3 and 4
⚠️
This is not the leg order most RGB LEDs use, and it is not the order you will find in most tutorials, which usually assume Red–common–Green–Blue with the shared leg second. The LED in this kit really does run Red, Green, common, Blue. Go by the length of the legs rather than by a diagram, and if the colours come out wrong at the end, this is the first thing to check.

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.

Jumper connecting the common cathode row to the negative rail
Step 9: the common cathode wired to the blue − rail

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
Three 330 ohm resistors added, one per colour channel
Step 10: one 330 Ω resistor for each colour channel
ℹ️
Which rows you land in does not matter, as long as each one is empty and no two resistors share a row. Spread them across different columns so the bodies do not sit on top of each other. Keep row 3 clear, since that is the common cathode, and row 9 as well, since that already belongs to the ground connection of the photoresistor.
⚠️
A resistor with both legs in the same row does nothing at all. Every hole in a row is one connection, so a resistor bridging two holes in the same row is simply short-circuited. The channel still lights, often brighter than the other two, but it is running with no current limiting whatsoever. Each resistor must span two different rows.

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.

ChannelFromTo
Redthe free row of the Red resistorj25 (IO2)
Greenthe free row of the Green resistorj26 (IO3)
Bluethe free row of the Blue resistorj27 (IO4)
Three coloured jumpers connecting the resistors to IO2, IO3 and IO4
Step 11: the three colour channels wired to IO2, IO3 and IO4
⚠️
Rows 25, 26 and 27 are one hole apart and tucked under the edge of the board, which makes them easy to miscount. Count in from j30 at the very end (GND, 3V3, IO5, IO4, IO3, IO2) rather than guessing from the middle.

12. Connect the board to your computer

The finished circuit powered over USB-C
Step 12: the finished circuit, powered over USB-C

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 IO5 rises.
  • 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))
ℹ️
This is the tidy answer to a real difference between the two platforms. An Arduino sketch writes 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.
ℹ️
The 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 valueWhat the code doesResult
0 → 1365red fades down while green fades upred → green
1366 → 2730green fades down while blue fades upgreen → blue
2731 → 4095blue stays full while red and green riseblue → 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.

The RGB LED glowing green on the finished circuit
Ordinary room light: the LED glows green
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.

The RGB LED glowing blue on the finished circuit
A phone torch on the sensor: the LED glows 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 doRoughly what you will readRGBColour
Seal your palm over it~90–150232, 22, 0red
Shade it with a hand, loosely~260–430190, 64, 0orange-red
Leave it in a lit room~1000–107061, 193, 0green
Shine a phone torch on it~2300–25300, 58, 196blue
ℹ️
Blue is the top of the range in practice, not white. The code fades blue into white across everything above 2730, but on this circuit a phone torch pressed against the sensor only reaches about 2530, so that last band needs direct sunlight or a proper lamp to enter at all. Do not read a missing white as a wiring fault. Red, on the other hand, is easy: an ordinary room already sits low in the range, so simply cupping a hand over the sensor is enough.
ℹ️
Your numbers will not match these exactly. They depend on the light in your room and on the particular photoresistor. What matters is that the value moves when the light changes, and that it moves across enough of the range to change the colour. If it sits at a fixed number no matter what you do, go back to step 7 and check that 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:

  1. 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.
  2. 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.
  3. Swap the jumpers at the board end. If red and blue are exchanged, swap the wires in j25 and j27. The console tells you what colour the board intended: if it prints RGB: 255 0 0 and you see blue, the two channels are crossed.
  4. A pale, washed-out colour that barely moves points at the atten setting, not at the LED. With ADC.ATTN_0DB in place of ADC.ATTN_11DB the 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.

ℹ️
A colour left glowing after you press Stop is normal. Nothing switches the channels off on the way out, so the last mixed colour stays lit until you run something else or reset the board.

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.