Programowanie w środowisku MicroPython (9). Captive Portal i serwer DNS

Programowanie w środowisku MicroPython (9). Captive Portal i serwer DNS

W poprzednim odcinku nauczyliśmy się, w jaki sposób można kontrolować ESP32 poprzez Wi-Fi i stronę w przeglądarce internetowej. W tym odcinku rozwiniemy tę funkcjonalność. ESP32 może pracować jako access point, do którego mogą podłączać się inne urządzenia. Aby nie trzeba było wpisywać żadnych adresów IP w pasku przeglądarki, zastosujemy captive portal, który automatycznie otworzy stronę po tym, gdy urządzenie podłączy się do naszego access pointa.

Sposób sterowania diodą, przedstawiony w poprzednim odcinku, miał kilka istotnych wad.

Po pierwsze, mikrokontroler ESP32 musiał połączyć się z jakąś siecią Wi-Fi. Aby to zrobić, musiał najpierw wiedzieć, jak nazywa się ta sieć i jakie jest do niej hasło. My zapisaliśmy te dane w pliku wifi_config.py, podając je w edytorze Thonny. W przypadku komercyjnego produktu musielibyśmy te dane dostarczyć w sposób bardziej przyjazny użytkownikowi, np. poprzez aplikację na telefonie, która prześle je przez Bluetooth, albo poprzez access point, podobnie jak to robią routery Wi-Fi.

Po drugie, aby otworzyć stronę internetową, generowaną przez ESP32, musieliśmy znać jego adres IP. Skąd go wziąć, jeżeli nie mamy dostępu do konsoli? Można je uzyskać m.in. ze strony konfiguracyjnej routera Wi-Fi, do którego ESP32 jest podłączone, ale to również może przerosnąć mniej zaawansowanego użytkownika.

Celem rozwiązań z tego odcinka kursu jest maksymalne uproszczenie procesu otwierania strony (generowanej przez ESP32) w przeglądarce internetowej na komputerze, telefonie czy tablecie. Zrobimy to tak, że jedyne, co użytkownik będzie musiał zrobić, to wyszukanie dostępnej sieci Wi-Fi i połączenie się z tą, która jest generowana przez ESP32. Następnie komputer lub telefon automatycznie otworzy stronę lub wyświetli pytanie, czy ma taką stronę otworzyć – ten szczegół jest już zależny od używanego systemu operacyjnego.

Niezależnie od tego, w opisanym scenariuszu użytkownik nie będzie musiał w żaden sposób konfigurować ESP32 i nie będzie potrzebował wiedzieć nic na temat adresów IP.

Strona WWW otwierająca się automatycznie po połączeniu z siecią Wi-Fi to tzw. captive portal. Najczęściej spotykamy to rozwiązanie, łącząc się do różnych hotspotów w miejscach publicznych. Po podłączeniu do takiej sieci często pojawia się nam strona, na której trzeba zaznaczyć checkbox potwierdzający akceptację regulaminu. Następnie strona się zamyka i mamy wtedy dostęp do Internetu.

Main

Program z tego odcinka kursu będzie dosyć rozbudowany. Dlatego podzielimy go na trzy moduły plus jeden główny o nazwie main, którego kod zaprezentowano na listingu 1.

# Plik main.py

import dns
import http
import wifi_ap

wifi_ap.init(„ESP32_HotSpot”)
dns.init()
http.init()

Listing 1. Kod pliku main.py

Jest to plik, który wykonuje się jako pierwszy. Jego zadaniem jest zaimportowanie modułów, które omówimy w dalszej części kursu oraz uruchomienie ich. Są to:

  • dns – serwer DNS, który wykorzystuje socket do obsługi zapytań na porcie 53, poprzez protokół UDP.
  • http – serwer HTTP, który korzysta z socketa do obsługi zapytań na porcie 80, poprzez protokół TCP.
  • wifi_ap – moduł, którego celem jest uruchomienie ESP32 w roli access pointa Wi-Fi; tworzy sieć o żądanej nazwie. Aby uprościć implementację, sieć jest całkowicie otwarta, nie ma hasła ani żadnych zabezpieczeń.

Access Point

Utworzenie własnej sieci w ESP32 jest bardzo proste. Wystarczy tylko kilka linii kodu. Na listingu 2 pokazano kod pliku wifi_ap.py, który tworzy access point.

# Plik wifi_ap.py

import network # 1

local_ip = „0.0.0.0” # 2

def get_ip(): # 3
return local_ip

def init(ssid): # 4
ap = network.WLAN(network.WLAN.IF_AP) # 5
ap.active(True) # 6
ap.config(essid=ssid, authmode=network.AUTH_OPEN) # 7

global local_ip # 8
local_ip = ap.ifconfig()[0] # 9
print(f”Access Point: {ssid} {local_ip}”) # 10

if __name__ == „__main__”: # 11
init(„ESP32-S3_HotSpot”)

Listing 2. Kod pliku wifi_ap.py

Całość opieramy na module network, który importujemy w linii 1. Jego dokładna specyfikacja dostępna jest pod adresem [2].

W linii 2 tworzymy zmienną local_ip, w której przechowywać będziemy adres IP modułu ESP32. Pamiętaj, że każde urządzenie w sieci, łącznie z routerem, ma swój unikalny adres IP. W MicroPythonie adres ten zapisywany jest jako string, w którym poszczególne składniki oddzielane są kropkami. W linii 3 mamy funkcję, która zwraca uprzednio zapisany adres IP. Ma to zastosowanie do serwera DNS, który opracujemy w dalszej części tego odcinka kursu.

W linii 4 rozpoczynamy funkcję init, której celem jest utworzenie sieci o nazwie podanej poprzez argument ssid. Najpierw musimy utworzyć instancję klasy, obsługującej sieć Wi-Fi, co robimy w linii 5. Jest to dość podobne do tego, co robiliśmy w poprzednim odcinku, jednak tam jako argument podaliśmy STA_IF, aby połączyć się z istniejącym access pointem jako klient. W tym przypadku stosujemy IF_AP, aby utworzyć własny, samodzielny access point.

W kolejnej linii aktywujemy naszą klasę, a w linii 7 konfigurujemy niezbędne parametry access pointa. Są to: nazwa sieci, którą podajemy poprzez argument nazwany essid oraz rodzaj zabezpieczeń zdefiniowany poprzez argument authmode. Mamy do wyboru różne współcześnie stosowane metody zabezpieczeń, takie jak WEP, WPA, WPA2, WPA3, itp. Aby użytkownik nie musiał podawać żadnych haseł, celowo zastosujemy metodę AUTH_OPEN, czyli nasza sieć jest publicznie dostępna dla każdego bez żadnych zabezpieczeń.

W linii 8 odwołujemy się do zmiennej globalnej local_ip. Brak tej linii spowodowałby, że w linijce 9 utworzyliśmy zmienną lokalną local_ip, która zostałaby usunięta po wyjściu z funkcji. Aby uzyskać adres IP, posługujemy się funkcją ifconfig z klasy ap. Zwraca ona listę różnych informacji, ale nam potrzebny jest tylko element zerowy. Dlatego po nawiasach okrągłych od razu stosujemy nawiasy kwadratowe z indeksem 0, aby do zmiennej local_ip zapisać tylko ten element, a resztę zignorować.

Na koniec wyświetlamy komunikat na konsoli w celu informacyjnym, aby dowiedzieć się, jaki adres IP ma ESP32 po utworzeniu access pointa. Domyślny adres to 192.168.4.1. Jeżeli jest potrzeba to można go zmienić na inny.

Linia 11 ma za zadanie szybko przetestować nasz access point i wykonuje się tylko wtedy, jeżeli plik wifi_ap.py jest uruchamiany przez Thonny, a nie importowany przez inny plik. Spróbuj. Wciśnij F5 i zobacz co się stanie. Powinieneś zobaczyć komunikat, jaki przedstawiono na rysunku 1.

Rysunek 1. Komunikaty na konsoli po uruchomieniu pliku wifi_ap.py

Uruchom skanowanie sieci Wi-Fi. Powinieneś zobaczyć, że dostępna jest sieć o nazwie ESP32_HotSpot (rysunek 2). Połącz się z nią.

Rysunek 2. Efekt skanowania sieci Wi-Fi

Otwórz teraz konsolę systemu (menu Start, wpisz cmd i wciśnij Enter). Następnie w konsoli wpisz polecenie ping 192.168.4.1. Program wyśle cztery testowe pakiety, by sprawdzić, czy połączenie działa. A jaki adres IP ma komputer, który podłączył się do ESP32? Jest on o jeden większy, czyli 192.168.4.2. Jego również można sprawdzić poleceniem ping. Na konsoli powinieneś zobaczyć komunikaty takie, jak na rysunku 3.

Rysunek 3. Testowanie połączenia z Wi-Fi przy pomocy konsoli systemowej

Serwer DNS

Serwer DNS pełni bardzo ważną rolę w świecie Internetu. Zajmuje się tłumaczeniem adresów przyjaznych człowiekowi, takich jak ep.com.pl na adresy IP, jak w tym przypadku 51.255.157.207.

Chcąc uzyskać funkcjonalność captive portal, musimy zbudować najpierw prosty serwer DNS. Po co? Współczesne komputery, smartfony i tablety, po połączeniu się z siecią Wi-Fi testują ją, próbując pobrać różne dane. Na przykład system Windows odpytuje serwer DNS o stronę pod adresem www.msftconnecttest.com. Uzyskuje jej adres IP, a następnie wysyła zapytanie GET, aby pobrać plik connecttest.txt, w którym powinna znajdować się zawartość „Microsoft Connect Test”. Takich testów jest wykonywanych całkiem sporo i niestety różnią się w zależności od systemu i jego wersji.

Celem naszego serwera DNS jest oszukanie tych wszystkich testów. Niezależnie od tego, jakie przyjdzie zapytanie do DNS, odpowiedzią zawsze będzie adres IP access pointa w ESP32. W ten sposób urządzenia testujące sieć wyślą kolejne zapytanie do serwera HTTP, który omawialiśmy w poprzednim odcinku kursu. Wystarczy tylko odrobinę zmodyfikować serwer HTTP, aby inne testowe zapytania otrzymywały w odpowiedzi stronę index.html.

Pisząc ten odcinek kursu, posłużyłem się tutorialem autorstwa Ansona van Dorena, który opracował Captive Portal na ESP8266 [3]. Uprościłem jego kod, aby zredukować go do jednej prostej funkcji, którą można będzie zastosować w tasku. Gotowe rozwiązanie zawarte jest w pliku dns.py, który przedstawiono na listingu 3.

# Plik dns.py

import _thread
import socket
import gc
import sys
import time
import wifi_ap

def decode_request(request): # 1
domain = „”
head = 12
length = request[head]

while length != 0:
label = head + 1
# add the label to the requested domain
# and insert a dot after
domain += request[label:label+length].decode(„utf-8”) + „.”
# check if there is another label after this one
head += length + 1
length = request[head]

response = request[:2]
# set response flags (assume RD=1 from request)
response += b”\x81\x80”
# copy over QDCOUNT and set ANCOUNT equal
response += request[4:6] + request[4:6]
# set NSCOUNT and ARCOUNT to 0
response += b”\x00\x00\x00\x00”

# ** create the answer body **
# respond with original domain name question
response += request[12:]
# pointer back to domain name (at byte 12)
response += b”\xC0\x0C”
# set TYPE and CLASS (A record and IN class)
response += b”\x00\x01\x00\x01”
# set TTL to 60sec
response += b”\x00\x00\x00\x3C”
# set response length to 4 bytes (to hold one IPv4 address)
response += b”\x00\x04”
# now actually send the IP address as 4 bytes (without the „.”s)
local_ip = wifi_ap.get_ip() # 2
response += bytes(map(int, local_ip.split(„.”)))

return response, domain # 3

def task(): # 4
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # 5
sock.bind((„”, 53)) # 6

while True: # 7
try:
gc.collect() # 8
request, addr = sock.recvfrom(1024) # 9
response, domain = decode_request(request) # 10
sock.sendto(response, addr) # 11
print(f”DNS - from {addr[0]} request {domain},
response is {wifi_ap.get_ip()}”)
except Exception as e:
sys.print_exception(e)

def init(): # 12
_thread.start_new_thread(task, ())

Listing 3. Kod pliku dns.py

Przejdźmy do linii 12, gdzie znajduje się funkcja init. Jej jedynym zadaniem jest wywołanie funkcji start_new_thread z modułu _thread, aby dodać nowy task. Jest on przekazywany jako pierwszy argument tej funkcji. Nasz task nie potrzebuje żadnych dodatkowych parametrów, zatem drugim argumentem tej funkcji jest pusta krotka ().

Task tworzymy w linii 4. Podobnie jak wszystkie inne taski, składa się z części wykonywanej tylko jeden raz, zaraz po uruchomieniu oraz pętli nieskończonej, która wykonuje się przez cały czas trwania programu.

W linii 5 tworzymy instancję socketa obsługującego zapytania serwera DNS. Zwróć uwagę, że drugi argument to SOCK_DGRAM i jest on inny niż w przypadku serwera HTTP, gdzie korzystaliśmy z postaci SOCK_STREAM. Jest to spowodowane tym, że serwer HTTP wykorzystuje protokół TCP, a DNS potrzebuje UDP. W linii 6 przypisujemy socket do konkretnego portu i tu również mamy pewną różnicę pomiędzy HTTP i DNS – ten pierwszy działa na porcie 80, a drugi na 53.

Następnie mamy prostą pętlę nieskończoną while True (linia 7). Pierwszym krokiem, nieobowiązkowym (lecz zalecanym) jest uruchomienie odśmiecacza pamięci collect z modułu gc (linia 8). W linii 9 jest metoda recvfrom z socketa, która zatrzymuje się i oczekuje aż zostaną odebrane jakieś dane. Po odebraniu zapytania, funkcja zwraca dwie zmienne: zapytanie trafia do zmiennej request, a w zmiennej addr znajduje się adres IP urządzenia, które to zapytanie przesłało.

W linii 10 wywołujemy funkcję decode_request. Jest to uproszczona funkcja opracowana przez wspomnianego wcześniej autora i zaczyna się w linii 1. Potraktujemy ją jako czarną skrzynkę, która robi jakąś „magię”, dekodując zapytanie i tworząc na nie odpowiedź. Na końcu funkcja pobiera adres IP access pointa z modułu wifi_ap, który stworzyliśmy wcześniej (linia 2), po czym zwraca gotową odpowiedź oraz nazwę szukanej domeny (linia 3).

Wracamy do pętli nieskończonej tasku. W linii 11 wysyłamy odpowiedź zwróconą przez funkcję decode_request pod adres zwrócony przez socket, a na koniec wyświetlamy komunikat na konsoli, abyśmy wiedzieli, jakie zapytania przetwarza nasz task.

Serwer HTTP (po raz kolejny)

Będziemy wykorzystywać dokładnie tę samą stronę index.html, jak w poprzednim odcinku, a plik z serwerem HTTP odrobinę zmienimy. Nie będziemy omawiać całego kodu – został on omówiony w 8. odcinku kursu MicroPythona, opublikowanym w EP 12/2025, a tutaj omówimy tylko, co należy zmienić, aby uzyskać funkcjonalność captive portal. Kod zaktualizowanego serwera HTTP przedstawiono na listingu 4.

# Plik http.py

import _thread
import esp32
import gc
import neopixel
import socket
import sys
import time
import wifi_ap
from machine import Pin

def index_html():
gc.collect()
content = „”
with open(„index.html”, encoding=”utf-8”) as file:
content += file.read()

if led[0] == (0x10, 0x00, 0x00):
color = „Czerwony”
elif led[0] == (0x10, 0x10, 0x00):
color = „Żółty”
elif led[0] == (0x00, 0x10, 0x00):
color = „Zielony”
elif led[0] == (0x00, 0x10, 0x10):
color = „Błękitny”
elif led[0] == (0x00, 0x00, 0x10):
color = „Niebieski”
elif led[0] == (0x10, 0x00, 0x10):
color = „Fioletowy”
elif led[0] == (0x10, 0x10, 0x10):
color = „Biały”
else:
color = „Czarny”

content = content.replace(„AA”, str(esp32.mcu_temperature()))
#content = content.replace(„BBB”, str(ap.status(„rssi”))) # 1
content = content.replace(„CCCCCCCCC”, color)

return content

def task():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind((„”, 80))
sock.listen()

while True:
try:
gc.collect()

conn, addr = sock.accept()
request = conn.recv(1024)

if request == b””:
print(f”HTTP - from {addr[0]} EMPTY request”)
conn.send(„HTTP/1.1 400 Bad Request\r\n”)
conn.send(„Connection: close\r\n”)
conn.sendall(„\r\n”)
conn.close()
continue

request = request.splitlines()[0]
print(f”HTTP - from {addr[0]} request {request}”)

print(„HTTP - response: „, end=””)

if b”GET / HTTP” in request:
conn.send(„HTTP/1.1 307 Temporary Redirect\r\n”)
conn.send(f”Location:
http://{wifi_ap.get_ip()}/index.html\r\n”)
conn.send(„Connection: close\r\n”)
conn.sendall(„\r\n”)

elif b”index.html „ in request:
print(„index.html”)
conn.send(„HTTP/1.1 200 OK\r\n”)
conn.send(„Content-Type: text/html\r\n”)
conn.send(„Connection: close\r\n”)
conn.send(„\r\n”)
conn.sendall(index_html())

elif b”favicon.ico” in request:
print(„favicon.ico - ignoring”)
conn.send(„HTTP/1.1 404 Not Found\r\n”)
conn.sendall(„\r\n”)

elif b”color” in request:
print(„color”)
if b”red” in request:
led[0] = (0x10, 0x00, 0x00)
elif b”yellow” in request:
led[0] = (0x10, 0x10, 0x00)
elif b”green” in request:
led[0] = (0x00, 0x10, 0x00)
elif b”cyan” in request:
led[0] = (0x00, 0x10, 0x10)
elif b”blue” in request:
led[0] = (0x00, 0x00, 0x10)
elif b”magenta” in request:
led[0] = (0x10, 0x00, 0x10)
elif b”white” in request:
led[0] = (0x10, 0x10, 0x10)
else:
led[0] = (0x00, 0x00, 0x00)

conn.send(„HTTP/1.1 200 OK\r\n”)
conn.send(„Content-Type: text/html\r\n”)
conn.send(„Connection: close\r\n”)
conn.send(„\r\n”)
response = index_html()
conn.sendall(response)

led.write()

elif b”connectivitycheck” in request: # 2
print(„Connectivity check”)
conn.send(„HTTP/1.1 204 No Content\r\n”)
conn.sendall(„\r\n”)

else: # 3
print(„Unknown request”)
conn.send(„HTTP/1.1 307 Temporary Redirect\r\n”)
conn.send(f”Location:
http://{wifi_ap.get_ip()}/index.html\r\n”) # 4
conn.send(„Connection: close\r\n”)
conn.sendall(„\r\n”)

conn.close()

except Exception as e:
sys.print_exception(e)

def init():
global led
led = neopixel.NeoPixel(Pin(38, Pin.OUT), 1)
led[0] = (0, 0, 0)
led.write()

_thread.start_new_thread(task, ())

Listing 4. Kod pliku http.py

Pierwszą istotną zmianą jest to że nie mamy możliwości podawania mocy sygnału RSSI (linia 1). Jest to funkcjonalność dostępna tylko wtedy, gdy ESP32 pracuje jako klient, a nie access point. Zatem ta linia została zakomentowana.

Kolejną modyfikacją jest obsługa „connectivitycheck” w zapytaniu (linia 2). Jeżeli takie zapytanie otrzymamy, to musimy na nie odpowiedzieć kodem 204 No Content.

W linii 3 zmienił się sposób reakcji na wszystkie nieobsługiwane zapytania. W takim przypadku odsyłamy komunikat o przekierowaniu 307 Temporary Redirect na stronę główną index.html, ale do jej adresu musimy dokleić adres IP access pointa z modułu wifi_ap (linia 4).

Testujemy!

Przegraj do pamięci ESP32 pliki index.html, dns.py, http.py, wifi_ap.py, a następnie uruchom plik main.py. Powinieneś zobaczyć komunikaty takie, jak na rysunku 1, a następnie nasza sieć Wi-Fi powinna być widoczna tak, jak pokazuje to rysunek 2. Połącz się z tą siecią.

Może to zająć kilkanaście sekund. W tym czasie na konsoli pojawi się lawina różnych komunikatów, co przedstawia rysunek 4 – widać, jak system testuje sieć Wi-Fi, a razem z nim uaktywniają się różne programy korzystające z Internetu.

Rysunek 4. Komunikaty na konsoli zaraz po podłączeniu się komputera do access pointa

Po chwili w okienku wyboru sieci powinien pojawić się komunikat, że jest „wymagana akcja”, aby uzyskać połączenie (rysunek 5). Klikamy go i powinna otworzyć się przeglądarka, a w pasku adresu powinien automatycznie wyświetlić się adres http://192.168.4.1/index.html.

Rysunek 5. Captive Portal prosi o otwarcie swojej strony

Otwiera się nasza strona, taka sama jak w poprzednim odcinku. Jedyną różnicą względem poprzedniego odcinka jest to, że nie podaje wartości RSSI (rysunek 6). Możemy klikać przyciski i w ten sposób zmieniać kolor diody WS2812 zamontowanej na płytce ESP32-DevKit-C.

Rysunek 6. Widok uruchomionej strony w przeglądarce

Tak przygotowany captive portal działa na systemach Windows, a także na większości telefonów z systemem Android. Tylko na jednym telefonie z Androidem nie udało mi się wywołać tej funkcjonalności. Nie miałem także możliwości, by to przetestować na iPhone’ach, Linuksie ani macOS.

Na zakończenie warto wspomnieć, że ESP32 ma wbudowane dwie karty sieciowe – jedna działa jako klient, a druga jako access point. Obie mają inne adresy MAC (różnią się o 1) i obie mogą działać jednocześnie. Zatem można zadać pytanie – czy ESP32 może połączyć się jako klient do access pointu, który sam tworzy? Sprawdź sam!

W następnym odcinku będziemy kontynuować temat komunikacji przez Wi-Fi i dowiemy się, jak poprzez MQTT możemy wysyłać i odbierać dane z chmury.

Dominik Bieczyński
leonow32@gmail.com

Zobacz więcej:
• Repozytorium kursu na GitHubie https://github.com/leonow32/micropython
• Dokumentacja modułu network https://docs.micropython.org/en/latest/library/network.html
• Captive Portal w ESP8266 https://github.com/anson-vandoren/esp8266-captive-portal/tree/master
Elektronika Praktyczna Plus lipiec - grudzień 2012

Elektronika Praktyczna Plus

Monograficzne wydania specjalne

Elektronik maj 2026

Elektronik

Magazyn elektroniki profesjonalnej

Raspberry Pi 2015

Raspberry Pi

Wykorzystaj wszystkie możliwości wyjątkowego minikomputera

Świat Radio maj - czerwiec 2026

Świat Radio

Magazyn krótkofalowców i amatorów CB

Automatyka, Podzespoły, Aplikacje maj 2026

Automatyka, Podzespoły, Aplikacje

Technika i rynek systemów automatyki

Elektronika Praktyczna maj 2026

Elektronika Praktyczna

Międzynarodowy magazyn elektroników konstruktorów

Elektronika dla Wszystkich maj 2026

Elektronika dla Wszystkich

Interesująca elektronika dla pasjonatów