Raspberry Pi Pico W captive portal (hotspot access point pop-up)

raspberry pi pico w captive portal set up

I took the time to figure out how to create a captive portal for the Raspberry Pi Pico W for you!

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.

If you wanted to visit a web page that your Pico W is serving, you will need to enter its address. For example, http://192.168.4.1.

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.

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. I used Thonny 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!

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")

This code was adapted from Kevin McAleer and improved upon. If you want to watch the explanation on how things work, watch his YouTube video.

Let me explain 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.

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")

The best part is the dns.run_catchall() function that runs a load of code that beginners won’t understand:

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, create a route is super easy. You just use @server.route, and pass a route in as well as the methods. You then define a function below with the request parameter.

Then, 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>

Leave a Comment