Skip to main content

6.2 WiFi LED control

In 6.1 Connecting and getting data the board did the asking: it joined your Wi-Fi network, requested a page from a website and printed the answer. This example turns that around. The board is now the one being asked. It becomes a small web server of its own, and a page you open in your phone or your computer switches a real LED on and off.

Nothing about the LED itself is new. It is the same LED, the same 330 Ω resistor and the same value() you have used since 1.2 LED Blinking. What is new is where the instruction comes from. Instead of being written into the script ahead of time, it arrives over the network the moment somebody presses a button.

In this documentation you will learn:

  • The difference between a client and a server, and why this example is the opposite of 6.1.
  • How to build a web server out of a raw socket, since MicroPython has no ready-made one.
  • How to read the requested address out of a request and route it yourself.
  • What the header lines of an HTTP answer are, and why a blank line has to follow them.
  • Why SO_REUSEADDR saves you from an error every time you restart the script.
  • How a few lines of JavaScript inside the page keep the status line up to date on their own.

Hardware required:

  • 1x Soldered NULA MINI board
  • 1x Breadboard
  • 1x LED (any colour)
  • 1x 330 Ω resistor
  • 3x Jumper wires
  • 1x USB-C cable
  • A Wi-Fi network, and a phone or computer joined to it
ℹ️
The 330 Ω resistor is not optional. An LED has almost no resistance of its own, so with nothing to limit the current it draws far more than either the LED or the pin is built for, and both can be damaged. The resistor goes in series with the LED, one after the other in the same chain, and it does not matter which way round it faces. Its bands read orange-orange-brown.
⚠️
This example does not need an internet connection. Unlike 6.1, nothing here reaches out to a website. The board and the device you browse from only have to be on the same local network, talking to each other through your router. That distinction matters in practice: a guest network, or any network with client isolation switched on, deliberately stops devices from seeing one another. The board will connect, the console will report success, and the page will still refuse to open. If that happens, put both onto an ordinary home or phone hotspot network instead.

Putting the components together

ℹ️
This example reaches only two pins on the board: IO4 in row 27 and GND in row 30, both on the f–j side. The board body covers columns f–i, so j27 and j30 are the holes you can actually get a wire into. Everything else happens further along the same f–j side, well clear of the board.
⚠️
Row 27 exists on both sides of the board. On the f–j side row 27 is IO4, which is the pin this example needs. On the a–e side the very same row 27 is IO19, a completely different pin. They are not connected to each other. Count your rows on the f–j side, the side where the silkscreen reads IO2 IO3 IO4 IO5 3V3 GND.

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 will occupy rows 25 to 30.

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

2. Bring IO4 out to a free row

IO4 is the pin that will drive the LED, and the board covers all of row 27 except the outermost hole. So the first wire simply carries that pin somewhere you can work: a jumper from j27 along to j19.

Blue jumper wire running along column j from row 27 to row 19
Step 2: IO4 brought out from j27 to j19

3. Add the 330 Ω resistor

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

330 ohm resistor bridging rows 19 and 14 on the f to j side
Step 3: the 330 Ω resistor bridging rows 19 and 14
ℹ️
Only the row number matters, not the letter. All five holes of one row on the f–j side are a single connection point inside the breadboard. That is why the photo shows the jumper in j19 and the resistor in i19, different holes in the same row, so they are already joined. Pick whichever hole the legs of your component reach comfortably.
ℹ️
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 14, the same row the resistor ends in, and its short leg (cathode) into row 12.

LED added, with one leg in row 14 alongside the resistor and the other in row 12
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 IO4; 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 12, where the short leg of the LED sits, across to the blue rail.
  • From j30 (GND) across to that same rail.
Two orange jumper wires, one from row 12 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 circuit is now a single unbroken chain: IO4 → row 19 → resistor → row 14 → LED → row 12 → the rail → GND. Current can only flow when the script drives IO4 high, which is exactly what the ON button will do.

6. Connect the board to your computer

Plug the USB-C cable into the board and into your computer. The PWR LED on the board lights up straight away, but the green LED on the breadboard stays dark. The script deliberately switches it off as it starts, so that the page and the hardware agree from the very first moment.

Completed circuit with the USB-C cable connected, PWR LED lit and the green LED dark
Step 6: powered up. The PWR LED is lit; the green LED is off, as the script intends

How the board becomes a web server

In 6.1 the board was a client. It picked a website, asked for a page and waited for the answer. Everything started with the board.

Here it is a server, which means it does the opposite: it sits and waits, and something else starts the conversation. When you type the address of the board into a browser, the browser sends a request and the board answers. The board never decides when this happens. Your finger on the ON button does.

There is no web server module, so we build one

This is the biggest difference from the Arduino version of this example. An Arduino sketch includes WebServer.h, creates WebServer server(80), registers a handler function for each address with server.on(), and lets the library do the rest.

MicroPython ships no such library. What it gives you instead is socket, the raw plumbing underneath, and a web server built from it is about twenty lines of your own code. That sounds like a downgrade, and in convenience it is, but it means nothing is hidden. Every line of the conversation between browser and board is something you wrote.

Opening the door

A server needs a port, which you can think of as a numbered door on the board. Port 80 is the standard door for web pages, which is why you can simply type an address without adding anything after it. Browsers already try door 80 by default.

Four calls open that door:

server = socket.socket()
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("", 80))
server.listen(2)

socket() creates it, bind() attaches it to port 80 on every network interface the board has, and listen(2) starts accepting connections, keeping up to two waiting in line.

⚠️
setsockopt with SO_REUSEADDR is the line you will miss when it is gone. A port stays claimed for as long as the socket that claimed it exists. Stopping a script the polite way runs the except KeyboardInterrupt block at the bottom, which calls server.close() and hands the port back, but a script that is cut off before it gets there leaves its socket behind, still holding port 80, and it does not time out and let go. Without this line the next run then fails immediately with OSError: [Errno 112] EADDRINUSE, and waiting does not help; only closing that socket or resetting the board frees the port. With this line, bind() succeeds anyway. It costs one line and saves you a reset almost every time you edit and re-run.

Waiting, then reading the request

connection, address = server.accept()
request = connection.recv(1024).decode()

accept() waits until a browser connects. It hands back two things: a connection to talk over, and the address of whoever connected. recv(1024) then reads up to 1024 characters of what they sent, and decode() turns those raw bytes into text.

ℹ️
accept() blocks. The script stops on that line and waits, sometimes for minutes, until a request arrives. This is why the loop looks so different from the Arduino version, where server.handleClient() returns immediately whether or not anything was waiting. Here the board is genuinely parked on that line, which is fine for this example, since answering the page is its only job.

Routing it yourself

The first line of any HTTP request looks like this:

GET /led/on HTTP/1.1

Three pieces separated by spaces: the method, the address, and the protocol version. The script pulls the middle one out:

first_line = request.split("\r\n")[0]
parts = first_line.split(" ")
path = parts[1] if len(parts) > 1 else "/"

split("\r\n")[0] takes the first line of the request, split(" ") breaks it into its three pieces, and [1] is the address. The if len(parts) > 1 else "/" on the end is a safety net for a malformed request that has no second piece, which would otherwise crash the server.

A chain of if and elif then decides what each address means. This is called routing, and it is the same list the Arduino handlers implement, written out in the open:

AddressWhat the script does
/led/ondrives IO4 high and answers on
/led/offdrives IO4 low and answers off
/led/statusreads IO4 and reports its current state
anything else, including /sends the web page itself
ℹ️
"on" if led.value() else "off" is a conditional expression: it picks the first value when the condition is true and the second when it is false, all on one line. And note what it is reading: led.value() on an output pin reads back what the pin is currently writing, so the status is a real measurement of a real pin rather than a variable the script hopes is still accurate.

Answering by hand

An HTTP answer is not just the content. It starts with a few lines that describe it, and the script writes them itself:

connection.send("HTTP/1.1 200 OK\r\n")
connection.send("Content-Type: " + content_type + "\r\n")
connection.send("Connection: close\r\n\r\n")
connection.sendall(body)

The first line carries the response code. 200 OK is "here you go", the same number 6.1 was pleased to receive. Content-Type tells the browser whether it is getting a web page (text/html) or plain text (text/plain).

⚠️
The \r\n\r\n at the end of the last header is doing real work. A blank line is what separates the headers from the content, and a browser keeps reading headers until it finds one. Send only \r\n there and the browser treats the beginning of your HTML as more headers, and the page never appears.

Then:

connection.close()

This ends that one conversation. Without it the browser keeps waiting for the rest of an answer that never comes, and the board slowly runs out of memory.

The page lives inside the script

The whole web page is stored in the script as one long piece of text, wrapped in triple quotation marks. That is Python letting a string run over many lines and contain single quotation marks without an escape character in front of every one. It is the same job R"rawliteral( ... )rawliteral" does in the Arduino sketch.

The page is HTML, the same language the website in 6.1 sent back to us. Now the board is the one sending it.

Inside the page is a short piece of JavaScript, which runs in the browser rather than on the board. It asks /led/status for the current state and writes it into the status line, then repeats that every two seconds:

updateStatus();
setInterval(updateStatus, 2000);

That is why the status line stays honest even if somebody else presses a button on another phone, and also why it can lag: the page can be up to two seconds behind the LED.

nulamini.local instead of an IP address

try:
network.hostname(HOSTNAME)
except Exception:
print("Setting the hostname is not supported on this firmware, use the IP address instead.")

network.hostname() claims a name on your local network, so that the board can answer to http://nulamini.local/ instead of a row of numbers. It is wrapped in try and except because not every MicroPython build offers the function, and a missing function should not stop the whole example.

ℹ️
If nulamini.local does not open, use the IP address instead. Name resolution on a local network is not supported everywhere. Windows 10 and 11, macOS and iOS all resolve .local names out of the box, but some Android versions and some routers do not. This is exactly why the script prints IP address:. Type those numbers into your browser and you reach the same page. The name is a convenience, not a requirement.

Stopping cleanly

The whole loop sits inside a try block that catches KeyboardInterrupt:

except KeyboardInterrupt:
print("Server stopped")
server.close()

KeyboardInterrupt is what pressing Stop in your editor, or Ctrl+C in a terminal, raises inside the script. Catching it gives the script a moment to close the socket, which frees the port for the next run instead of leaving it claimed.


Setting up Wi-Fi credentials

Before running the script, change these two lines to match your own network:

ssid = "your ssid"
password = "your password"
⚠️
Make sure the network name and password are written exactly as they appear, they are case-sensitive.

Code

Below is the complete code for this example:

# The network module contains everything needed to join a Wi-Fi network.
import network

# The socket module is the lowest level of network communication: it lets us listen for other devices that want to
# talk to us. MicroPython has no ready-made web server, so we build a small one out of a socket ourselves.
import socket

# Pin controls the LED, and time gives us the pauses we need while connecting.
from machine import Pin
import time

# These two variables hold the name of your Wi-Fi network (the SSID) and its password. Your phone or computer has to
# be on the same network as the board, otherwise it will not be able to reach the page.
ssid = "your ssid"
password = "your password"

# This is a variable to which we pass the number of pin that we had connected the LED to.
# Remember that the LED needs a 330 Ohm resistor in series with it.
LED_PIN = 4

# This is the name the board will try to claim on your network. If your network supports it, you can then open
# http://nulamini.local/ in a browser instead of having to remember the address made of numbers.
HOSTNAME = "nulamini"

# This is the web page itself, written in HTML and stored as one long piece of text. The three quotation marks let us
# write many lines of text in one go, including single quotation marks, without having to escape every one of them.
# The page contains two buttons and a small piece of JavaScript, which is code that runs inside the browser and asks
# the board for the current LED state every two seconds so the displayed status stays up to date on its own.
HTML_PAGE = """<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NULA MINI LED Control</title>
<style>
body { font-family: Arial; text-align: center; margin-top: 50px; }
button { padding: 15px 30px; margin: 10px; font-size: 20px; }
.status { font-size: 24px; margin-top: 20px; }
</style>
</head>
<body>
<h1>NULA MINI LED Control</h1>
<button onclick="fetch('/led/on').then(()=>updateStatus())">ON</button>
<button onclick="fetch('/led/off').then(()=>updateStatus())">OFF</button>
<div class="status" id="status">Loading status...</div>

<script>
// Function that requests LED status from the NULA MINI and updates the page
async function updateStatus() {
let res = await fetch('/led/status');
let text = await res.text();
document.getElementById('status').innerHTML = 'LED is ' + text.toUpperCase();
}

// Run immediately after page load and update every 2 seconds
updateStatus();
setInterval(updateStatus, 2000);
</script>
</body>
</html>"""

# Here we create our Pin object for the LED and immediately write 0 to it, so that the LED starts out switched off
# and the status shown on the page matches reality.
led = Pin(LED_PIN, Pin.OUT)
led.value(0)

# Here we create our network object and switch the Wi-Fi hardware on. network.STA_IF means the board joins someone
# else's network, the same way your phone does.
wlan = network.WLAN(network.STA_IF)
wlan.active(True)

# Here we claim our name on the network. We wrap it in a try block because not every firmware build offers this
# function, and a missing function should not stop the whole example.
try:
network.hostname(HOSTNAME)
except Exception:
print("Setting the hostname is not supported on this firmware, use the IP address instead.")

# connect() starts the connection attempt. It does not wait for the connection to finish, so we wait for it
# ourselves in the loop below. isconnected() tells us whether we are online yet.
print("Connecting to WiFi", end="")
wlan.connect(ssid, password)
while not wlan.isconnected():
time.sleep(0.5)
print(".", end="")

# ifconfig() returns the address the router handed out to our board as its first value. Typing this address into a
# browser on the same network opens the page we prepared above.
print()
print("WiFi connected!")
print("IP address:", wlan.ifconfig()[0])
print("Access the board in your browser at: http://" + HOSTNAME + ".local/")


def send_response(connection, content_type, body):
# This is a function we wrote ourselves. It answers one request.
# Every HTTP answer starts with a few lines that describe it before the content itself: the response code, where
# 200 means "here you go", and the type of the content we are sending. A blank line then separates those lines
# from the content.
connection.send("HTTP/1.1 200 OK\r\n")
connection.send("Content-Type: " + content_type + "\r\n")
connection.send("Connection: close\r\n\r\n")
connection.sendall(body)


# Here we open the door and start listening. socket() creates the socket, setsockopt() lets us reuse the same port
# right away when we restart the program instead of having to wait for the system to release it, bind() attaches us
# to port 80, and listen() starts accepting connections.
# A port is like a door number on the board: port 80 is the standard door for web pages, which is why browsers try
# it by default and why we do not have to type it into the address bar.
server = socket.socket()
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("", 80))
server.listen(2)
print("HTTP server started")

# We wrap the whole loop in a try block so that stopping the program with Ctrl+C, or with the stop button of your
# editor, closes the socket properly instead of leaving it claimed.
try:
while True:

# accept() waits until a browser connects to us. It gives us back two things: a connection we can talk over,
# and the address of whoever connected.
connection, address = server.accept()

# recv() reads what the browser sent us, up to 1024 characters of it. The first line of any request names
# the address being asked for, which is what we need to decide how to answer.
request = connection.recv(1024).decode()
first_line = request.split("\r\n")[0]

# A request line looks like "GET /led/on HTTP/1.1", so splitting it on the spaces and taking the middle
# piece gives us the address on its own.
parts = first_line.split(" ")
path = parts[1] if len(parts) > 1 else "/"

# Here we decide what to do with each address. This is called routing: every address gets its own answer.
# The addresses starting with /led are the ones the buttons and the JavaScript in the page ask for, and they
# answer with a short piece of plain text rather than a whole page.
if path == "/led/on":
led.value(1)
print("LED ON")
send_response(connection, "text/plain", "on")

elif path == "/led/off":
led.value(0)
print("LED OFF")
send_response(connection, "text/plain", "off")

elif path == "/led/status":

# value() reads back what the pin is currently writing, which tells us whether the LED is on. The if and
# else on one line is a short way of choosing between two values.
send_response(connection, "text/plain", "on" if led.value() else "off")

else:

# Any other address, including the plain "/", gets the web page itself.
send_response(connection, "text/html", HTML_PAGE)

# close() ends this one conversation. This matters: without it the browser would keep waiting and the board
# would slowly run out of memory.
connection.close()

except KeyboardInterrupt:

# Stopping the program closes the door behind us, so the port is free the next time we start.
print("Server stopped")
server.close()

What you should see

Press Run. The board reports its whole start-up sequence and then goes quiet, waiting for a browser:

Connecting to WiFi......
WiFi connected!
IP address: 192.168.75.75
Access the board in your browser at: http://nulamini.local/
HTTP server started

Reading that from the top:

  • The dots are the progress indicator. Each one is a single pass of the wait loop, and each pass takes 500 ms, so the row of dots is a stopwatch. Six dots here means about three seconds to join the network. Expect somewhere between four and eight; the exact number changes from run to run, because it depends on how quickly your router answers.
  • IP address: 192.168.75.75 is the address the router handed out. Yours will differ. Keep it. It is your fallback if nulamini.local will not open.
  • HTTP server started is the last line. From here the board is parked on accept(), listening.
⚠️
The wait loop has no timeout. If the network name or password is wrong, isconnected() never turns True, so the board prints dots forever and never gets as far as reporting an error. With a network name that does not exist it will happily print a hundred dots and keep going. Since a successful connection takes fewer than ten, a dot row still growing after twenty is not a slow network. It is almost always a typo in the credentials. Note that the name has to match the network exactly, spaces and capital letters included.

Opening the page

With the board reporting HTTP server started, open http://nulamini.local/ on any phone or computer on the same network. The page is deliberately plain:

Browser showing the NULA MINI LED Control page with ON and OFF buttons and the status line reading LED is OFF
The page as it first opens. The status line already reflects reality: the LED is off

The status line is not a guess. Before the page has finished loading, the JavaScript has already asked /led/status, and the board has read IO4 and answered. LED is OFF is a real measurement of a real pin, and the breadboard agrees:

The completed circuit with the green LED dark, matching the LED is OFF status shown in the browser
The hardware side of the same moment: the green LED is dark, exactly as the page reports

Now press ON:

The same page after pressing ON, with the status line reading LED is ON
After pressing ON, the status line follows

And the LED on the breadboard lights up:

The green LED on the breadboard lit up after pressing the ON button in the browser
The same circuit, unchanged since step 6, now with IO4 driven high from a browser

Nothing about the wiring moved between those two photos. The only difference is the voltage on IO4, and that came from a button press somewhere else on your network.

The press travels a long way for something that looks so simple. Your finger triggers a fetch('/led/on') in the browser, which sends an HTTP request across your router to the board. accept() returns, recv() reads the request line, the if chain matches /led/on, and led.value(1) runs. Current then flows from IO4 through the resistor, through the LED and into the rail. Pressing OFF does the same journey in reverse.

The console keeps a log of it too, because each branch prints as it fires:

LED ON
LED OFF
LED ON
ℹ️
The status line can be up to two seconds stale. It refreshes on a two-second timer, so if you press a button on one phone while watching the page on another, the second one catches up on its next poll rather than instantly. The LED itself changes immediately. Only the text lags.
ℹ️
Because the page polls every two seconds, the board is answering a request every two seconds for as long as any browser has the page open. That is why the /led/status branch does not print anything: the console would fill up with it.

If it does not work

  • OSError: [Errno 112] EADDRINUSE on the second run. The previous run did not release port 80, and it will not release it on its own. The SO_REUSEADDR line is there to make the bind succeed anyway, so if you see this error the first thing to check is that the line is still in your script. Failing that, reset the board, which clears the old socket for certain.
  • The page will not open, but the console says HTTP server started. The board is listening and the browser cannot reach it, which means the two are not on the same network, or the network isolates its clients. Try the IP address before the .local name, and try a phone hotspot if you are on a guest network.
  • Dots that never stop. Wrong credentials, or a 5 GHz network. The C6 has no 5 GHz radio.

Full example

Check out the full example code on the link below:

6.2_Wi-Fi_LED_Control.py

Example that shows how to control an LED on the NULA MINI from a web page, served by the board itself out of a raw socket and reachable at nulamini.local.