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_REUSEADDRsaves 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
Putting the components together
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
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.

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.

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.

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

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.

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.

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:
| Address | What the script does |
|---|---|
/led/on | drives IO4 high and answers on |
/led/off | drives IO4 low and answers off |
/led/status | reads 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).
\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.
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"
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.75is the address the router handed out. Yours will differ. Keep it. It is your fallback ifnulamini.localwill not open.HTTP server startedis the last line. From here the board is parked onaccept(), listening.
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:
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:

Now press ON:
And the LED on the breadboard lights up:

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
/led/status branch does not print anything: the console would fill up with it.If it does not work
OSError: [Errno 112] EADDRINUSEon the second run. The previous run did not release port 80, and it will not release it on its own. TheSO_REUSEADDRline 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.localname, 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.