How to Make a Captive Portal for your Business with a Raspberry Pi Pico W
If you’re running a business, then setting up a captive portal can be an important tool for your network. So, in this article, I’ll show you how to create a captive portal with a Raspberry Pi Pico W.
A captive portal provides a secure way to offer WiFi to guests, customers, or clients. It requires that users log in or authenticate themselves before they can use the internet.
Think of how it works at an airport or at a coffeeshop chain.
A captive portal allows you to both control and monitor your network. It also provides an opportunity to extend your brand. You can customize the captive portal with your logo.
What does a captive portal do?
When you log onto a Pico W that’s acting as an access point, you will not get a web page served to you. With a captive portal implemented, once your WiFi connects to your hotspot, a web page will popup.
Therefore, you can skip the step of connecting to the network, then going to your browser and manually typing in an IP address.
It’s at this point that I have to credit Kevin McAleer for his video that serves as the basis for this tutorial.
Creating a captive portal with a Pico W is a fantastic opportunity. Not only is a captive portal imperative for your business’s network. A Raspberry Pi Pico W is just a few Euros at most, so it’s also an extremely cost-effective solution.
Goals for this tutorial
Kevin’s tutorial only shows you how to trigger a captive portal on an Apple device.
This tutorial will go further by explaining to you how to trigger the pop-up captive portal on Android, Windows and Apple devices.
We will use Phew by Pimoroni, which is a library for a Pico W web server. It’s a bit like what Express is to NodeJS.
I’ve tested my code on four devices:
- Apple iPhone 8 with OS version 15.4.1 ✔️
- Amazon Fire tablet with Android 9 ✔️
- Windows 10 PC ✔️
- Google Pixel 6 with Android 13 ✔️
- Samsung Note 9 with Android 10 ❌
The code worked on all of them except my Samsung Note 9, and I haven’t tested it on Linux.
Use cases?
This is a bit of a tough one.
Initially, I wanted to create something that would Rickroll someone, but I realized I can’t embed a YouTube video because there will be no internet connection since the Pico W does not have an internet connection.
Then, I wanted to create a portable digital business card where people would connect to the Pico W and see a few links. Sort of like those Instagram link-in-bio directories. Again, no internet…
I suppose one of the biggest advantages of the Pico W is that it allows you to control components wirelessly.
In our components mega-tutorial, I talked about how you can avoid using physical buttons and knobs to control LEDs, buzzers and servos by using a web interface instead. If you implement these into your captive portal, you can more easily control your devices as you won’t have to log into 192.168.4.1 every time you want to get to a control panel.
Of course, I’d love to hear what you think is possible with a captive portal. Leave a comment below!
How to trigger the pop-up
When you connect to a wireless network, Windows, Android and Apple devices ping different websites.
Each of these pings require a reply. The right reply unlocks the pop-up. Here’s how the convo goes depending on OS:
Microsoft Windows 10
MS: “Psst… is /connecttest.txt there?”
Pico W: “Yeah, 200“.
MS: “OK, take me to /redirect“
Pico W: “Um, yeah, 302, go to http://pico.wireless/ where we’ll serve you index.html“.
Amazon Fire Android 9
AMZ: “Psst.. where’s /generate_204 there?”
Pico W: “By default, I was gonna say 204, but I know what you really want is 302 redirect to http://pico.wireless/ (which renders index.html aka your captive page)”
Apple iPhone 8 with iOS 15
iPhone: “I’m cool and all so I’m just looking for the file /hotspot-detect.html“
Pico W: “Yeah, go to http://pico.wireless/“
iPhone: “Hmm, seems completely different from what I asked for but I trust you so I am going to go there now.”
Response code 200: “OK”, 204: “No Content”, 302: “Found”.
As you can see, different devices ask for different routes and different web pages. And if you reply correctly, they’ll trigger the pop-up.
Here’s a summary of what you need to send and receive:
Windows 10
URL | Triggering response |
www.msftconnecttest.com/ncsi.txt | 200 OK |
www.msftconnecttest.com/connecttest.txt | 200 OK |
www.msftconnecttest.com/redirect | 302 redirect (to captive portal, e.g. index.html) |
Android 9, 10
URL | Triggering response |
connectivitycheck.gstatic.com/generate_204 | 302 redirect (to captive portal page) |
Other Android versions might query other URLs. I believe the newer Android versions will have /generate_204 as a fallback.
Here are two resources for older Androids:
https://lemariva.com/blog/2017/11/white-hacking-wemos-captive-portal-using-micropython
iOS 15
URL | Triggering response |
captive.apple.com/hotspot-detect.html | 200 OK (respond with a web page) |
My Samsung Note 9 running Android 10 was the hardest to crack. Ostensibly, you would follow typical Android patterns but alas, nothing worked!
I did manage to get the “Sign-in required” prompt but only after changing the DNS to a public LAN IP (an IP outside of the ‘10.0.0.0/8, 172.16.0.0/12, or 192.168.0.0/16’ range). See this Stack Exchange comment.
But doing the above broke every other device’s captive portal…
Pimoroni Phew library for Pico W captive portal
Pimoroni’s Phew library makes it so much easier to create a captive portal because they’ve done all the heavy lifting.
First, you need to download the library. I used the 0.0.3 version which can be downloaded here.
Then, extract the files and upload the folder named phew onto your Pico W. Personally, I used the Thonny IDE and you can learn how to upload files here.
Let’s code the hotspot pop-up!
So, let me begin with main.py:
from phew import logging, server, access_point, dns
from phew.template import render_template
from phew.server import redirect
DOMAIN = "pico.wireless" # This is the address that is shown on the Captive Portal
@server.route("/", methods=['GET'])
def index(request):
""" Render the Index page"""
if request.method == 'GET':
logging.debug("Get request")
return render_template("index.html")
# microsoft windows redirects
@server.route("/ncsi.txt", methods=["GET"])
def hotspot(request):
print(request)
print("ncsi.txt")
return "", 200
@server.route("/connecttest.txt", methods=["GET"])
def hotspot(request):
print(request)
print("connecttest.txt")
return "", 200
@server.route("/redirect", methods=["GET"])
def hotspot(request):
print(request)
print("****************ms redir*********************")
return redirect(f"http://{DOMAIN}/", 302)
# android redirects
@server.route("/generate_204", methods=["GET"])
def hotspot(request):
print(request)
print("******generate_204********")
return redirect(f"http://{DOMAIN}/", 302)
# apple redir
@server.route("/hotspot-detect.html", methods=["GET"])
def hotspot(request):
print(request)
""" Redirect to the Index Page """
return render_template("index.html")
@server.catchall()
def catch_all(request):
print("***************CATCHALL***********************\n" + str(request))
return redirect("http://" + DOMAIN + "/")
# Set to Accesspoint mode
# Change this to whatever Wifi SSID you wish
ap = access_point("Pico W Captive")
ip = ap.ifconfig()[0]
# Grab the IP address and store it
logging.info(f"starting DNS server on {ip}")
# # Catch all requests and reroute them
dns.run_catchall(ip)
server.run() # Run the server
logging.info("Webserver Started")
To be clear, I adapted this code from Kevin McAleer and altered it for our purposes. If you want to watch Kevin’s in-depth explanation on how things work, watch this YouTube video.
But let me briefly go over the different sections.
There are three sections with comments “#android redirects”, “#apple redir” and “# microsoft windows redirects”. These sections handle the tests each operating system puts out and replies with the right response.
At the end of the responses, they be redirected to http://{DOMAIN}/, except for the Apple route (because Kevin wrote it that way and if it ain’t broken…).
Redirecting to DOMAIN, declared as “pico.wireless” would give us a less unsightly URL.
So, instead of seeing “www.msftconnecttest.com” on the address bar, we will see “pico.wireless” on the address bar. Of course, this is something to keep in mind for your business’s captive portal.
Pimoroni Phew reduces the code you need to write
The beautiful thing about Phew is that you write significantly less code than if you were to write it from scratch.
For example, if you wanted to launch the SoftAP on the Pico W, you would write code that’s something like this:
import network
# start up network in access point mode
wlan = network.WLAN(network.AP_IF)
wlan.config(essid=ssid)
if password:
wlan.config(password=password)
else:
wlan.config(security=0) # disable password
wlan.active(True)
return wlan
With Pimoroni’s Phew, your burden is reduced to:
ap = access_point("Pico W Captive")
In my opinion, the best part is the dns.run_catchall() function that runs a load of vital, but complicated code:
import uasyncio, usocket
from . import logging
async def _handler(socket, ip_address):
while True:
try:
yield uasyncio.core._io_queue.queue_read(socket)
request, client = socket.recvfrom(256)
response = request[:2] # request id
response += b"\x81\x80" # response flags
response += request[4:6] + request[4:6] # qd/an count
response += b"\x00\x00\x00\x00" # ns/ar count
response += request[12:] # origional request body
response += b"\xC0\x0C" # pointer to domain name at byte 12
response += b"\x00\x01\x00\x01" # type and class (A record / IN class)
response += b"\x00\x00\x00\x3C" # time to live 60 seconds
response += b"\x00\x04" # response length (4 bytes = 1 ipv4 address)
response += bytes(map(int, ip_address.split("."))) # ip address parts
socket.sendto(response, client)
except Exception as e:
logging.error(e)
def run_catchall(ip_address, port=53):
logging.info("> starting catch all dns server on port {}".format(port))
_socket = usocket.socket(usocket.AF_INET, usocket.SOCK_DGRAM)
_socket.setblocking(False)
_socket.setsockopt(usocket.SOL_SOCKET, usocket.SO_REUSEADDR, 1)
_socket.bind(usocket.getaddrinfo(ip_address, port, 0, usocket.SOCK_DGRAM)[0][-1])
loop = uasyncio.get_event_loop()
loop.create_task(_handler(_socket, ip_address))
Phew also makes it easy to write routes. All you have to do is to write something like this:
@server.route("/[your_route_here]", methods=["GET"])
def your_function_here(request):
pass
@server.catchall()
def catchall(request):
pass
As you can see above, creating a route is super easy. All you need to do is use @server.route, and pass a route in as well as the methods. Then define a function below with the request parameter.
Finally, there’s the @server.catchall() function, which handles all routes that you haven’t assigned.
Phew makes it super easy!
Visit Phew’s Github repo here.
index.html
It’s a real simple proof-of-concept index.html that puts out a <h1> saying “Pico W Captive Portal”.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pico W Captive Portal</title>
</head>
<body>
<h1>Pico W Captive Portal</h1>
</body>
</html>
Conclusion
So there you have it. That’s all you need to know to set up a captive portal on a Pico W for your business.
If you have any questions, don’t hesitate to let us know in the comments below!
A good replacement for a piratebox (although without chat feature and upload).
This is exactly what I was looking for!
Things mostly work, however the website isn’t popping up on it’s own, and instead I see an error:
2023-03-31 12:02:35 [info / 160kB] > GET /generate_204 (302 Found) [236ms]
Task exception wasn’t retrieved
future: coro=
Traceback (most recent call last):
File “uasyncio/core.py”, line 1, in run_until_complete
File “phew/server.py”, line 242, in _handle_request
File “phew/server.py”, line 161, in _parse_headers
ValueError: need more than 1 values to unpack
Wonder if maybe it has something to do with my old phone still being Android 6..
How would one edit that code to include the HTML code inside the program? I had a go and it didn’t work for me.
I find the program flow very confusing.
Hi! I’m happy to help! Could you describe your problem in more detail and tell me which device you’re using for the captive portal?
Howdy! I’m trying to run this on a Pico W.. the AP starts up, the DNS starts up, the web server either never starts or take a looooooong time (several minutes) to start up. Do you have any ideas/clues/suggestions as to why? Thanks!
Can you please help me understand which line of code enforces/auto-opens the captive portal on client’s browser, after successful connection to the hotspot? It doesn’t seem to auto open anything. I am connecting from my laptop.
Thanks for the good description.
I would like to add a second or even third SSID to the same AP. Is there a way to do that with Phew?
Any thoughts?
No you would need to use a raspberry pi pico for each ssid.