How to Make a Captive Portal for your Business with a Raspberry Pi Pico W

raspberry pi pico w captive portal set up

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

URLTriggering response
www.msftconnecttest.com/ncsi.txt200 OK
www.msftconnecttest.com/connecttest.txt200 OK
www.msftconnecttest.com/redirect302 redirect (to captive portal, e.g. index.html)

Android 9, 10

URLTriggering response
connectivitycheck.gstatic.com/generate_204302 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

https://enterprisenetworkingatlarge.wordpress.com/2018/04/21/captive-portal-detection-on-android-and-others-client-vendors-vs-ap-vendors/

iOS 15

URLTriggering response
captive.apple.com/hotspot-detect.html200 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.

By the end of the tutorial, you should have a directory that looks like this.

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!

7 Comments

  1. cinos on January 8, 2023 at 4:11 pm

    A good replacement for a piratebox (although without chat feature and upload).

  2. OkinKun on March 31, 2023 at 5:07 pm

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

  3. Dingleberry on June 21, 2023 at 10:57 pm

    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.

    • Adam Bobeck on June 26, 2023 at 2:51 pm

      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?

  4. NewbiePicoWHacker on October 1, 2023 at 3:36 pm

    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!

  5. Prithwiraj Bose on November 5, 2023 at 3:43 pm

    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.

  6. Peter on November 24, 2023 at 8:13 pm

    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?

Leave a Comment