Gniazda (ang. sockets) stanowią podstawę komunikacji przez internet.
Ten tutorial pokazuje, jak rozpocząć pracę z gniazdami, aby zaimplementować klientów HTTP i HTTPS w języku MicroPython na mikrokontrolerach.
- Źródło: ESP32 In MicroPython: Client Sockets
Tutorial
Wprowadzenie
Jeśli chcesz wyjść poza prostego klienta, musisz skorzystać z bardziej ogólnej metody połączenia sieciowego. Najczęstszym sposobem jest użycie „gniazd”. Jest to szeroko wspierany standard internetowy i większość serwerów obsługuje połączenia gniazdowe.
MicroPython wspiera ograniczoną wersję pełnego modułu Python Sockets. Części modułu, które nie są obsługiwane, dotyczą głównie połączeń za pomocą sieci nie-IP, więc są zazwyczaj mało istotne.
Najważniejsze jest to, że gniazda są bardzo ogólnym sposobem na nawiązanie dwukierunkowego połączenia między klientem a serwerem. Klient może utworzyć gniazdo do przesyłania danych między sobą a serwerem, a serwer może użyć gniazda do akceptowania połączenia od klienta. Jedyna różnica między tymi sytuacjami polega na tym, że serwer musi albo sondować, albo używać przerwań, aby wykryć nową próbę połączenia.
Tworzenie Obiektów Gniazd
Zanim będziesz mógł rozpocząć wysyłanie i odbieranie danych, musisz utworzyć obiekt gniazda:
import socket
s = socket.socket()
Ten domyślny konstruktor tworzy gniazdo odpowiednie do połączeń IPv4.
Jeśli chcesz określić typ tworzonego gniazda, musisz użyć:
socket.socket(af=socket.AF_INET, type=socket.SOCK_STREAM, proto=socket.IPPROTO_TCP)
Parametr af
określa rodzinę adresów i może być albo AF_INET
dla IPv4, albo AF_INET6
dla IPv6 – w chwili pisania tylko IPv4 jest obsługiwane. Parametr type
wskazuje na SOCK_STREAM
lub SOCK_DGRAM
. Domyślnie SOCK_STREAM
odpowiada zwykłemu połączeniu TCP/IP używanemu do przesyłania stron internetowych i plików w ogóle. Jest to połączenie trwałe i korygowane błędami, podczas gdy SOCK_DGRAM
wysyła pojedyncze pakiety bez sprawdzania błędów lub potwierdzenia, że dane zostały odebrane. Ostatni parametr, proto
, ustawia dokładny typ protokołu, ale ponieważ obsługiwane są tylko IPPROTO_TCP
i IPPROTO_UDP
, jest to ustawiane zgodnie z parametrem type
:
type | proto |
---|---|
SOCK_STREAM | IPPROTO_TCP |
SOCK_DGRAM | IPPROTO_UDP |
Adresy Gniazd
Po zdefiniowaniu typu połączenia, musisz określić adresy zaangażowane w połączenie. Ponieważ gniazda mogą być używane do łączenia się z różnymi typami sieci, format adresu może się znacznie różnić i nie musi być prostym URL-em lub adresem IP. Z tego powodu gniazda używają własnej wewnętrznej reprezentacji adresów i musisz użyć metody getaddrinfo
:
socket.getaddrinfo(host, port, af=0, type=0, proto=0, flags=0)
Pierwsze dwa parametry są znajome i proste. Host musi być określony za pomocą URL-a lub adresu IP. Drugi to numeryczny port do użycia z gniazdem. Kolejne trzy parametry są takie same jak użyte do tworzenia gniazda. Generalnie używasz tych samych wartości parametrów dla adresu przeznaczonego do użycia z gniazdem jak te używane do tworzenia gniazda. Jeśli nie określisz parametru, getaddrinfo
zwróci listę krotek dla każdego możliwego typu adresu. Każda krotka ma następujący format:
(af, type, proto, canonname, sockaddr)
gdzie af
, type
i proto
są takie jak wcześniej, canonname
to kanoniczna nazwa dla typu adresu, a sockaddr
to faktyczny adres, który również może być krotką. W przypadku adresu IP, sockaddr
to krotka w formacie:
(IP, port)
W chwili pisania, ESP32 obsługuje tylko IPv4 i zwraca tylko listę z jedną krotką. Jedyną naprawdę użyteczną częścią tej krotki jest ostatni element, który jest krotką IP używaną przez większość metod gniazd. Możesz skrócić wszystko, po prostu tworząc krotkę IP i nie używać getaddrinfo
. Jednak powinieneś używać getaddrinfo
, aby zapewnić kompatybilność z pełnym modułem gniazd i na potrzeby przyszłego rozwoju.
Na przykład:
ai = socket.getaddrinfo("www.example.com", 80, socket.AF_INET, socket.SOCK_DGRAM)
zwraca listę:
[(2, 1, 0, '', ('93.184.216.34', 80))]
a rzeczywistą krotką, której używamy w wywołaniach metod gniazda, jest:
('93.184.216.34', 80)
Aby wyodrębnić tę końcową krotkę, używamy:
addr = ai[0][-1]
Jeśli chodzi o adresy, istnieją dwie funkcje, które mogą być używane do konwersji adresu IP w formie „kropkowanej” na wartość bajtową i odwrotnie:
socket.inet_ntop(socket.AF_INET, bytes)
daje tekstową formę kropkowaną, a:
socket.inet_pton(socket.AF_INET, string)
daje binarną formę adresu IP.
Gniazda Klienta w MicroPython
Wprowadzenie
Gdy mamy obiekt gniazda i możliwość określenia adresu, musimy połączyć go z innym gniazdem, aby stworzyć dwukierunkowy kanał komunikacyjny. Dane mogą być zapisywane i odczytywane w obu gniazdach, które tworzą połączenie. Dokładny sposób połączenia gniazd zależy od tego, które jest klientem, a które serwerem. W tej sekcji zajmiemy się tym, jak połączyć się z serwerem. Domyślnie wszystkie metody gniazd są blokujące. Później omówimy działanie nieblokujące.
Połączenie Gniazda Klienta z Gniazdem Serwera
Aby połączyć gniazdo z serwerem, wystarczy użyć:
socket.connect(address)
gdzie address
to adres gniazda serwera, z którym próbujesz się połączyć i musi być określony jako krotka IP:
(ip, port)
Na przykład:
socket.connect(('93.184.216.34', 80))
Chociaż można użyć krotki IP, częściej używa się socket.getaddrinfo
, aby ją wygenerować.
Zawsze powinieneś zamknąć gniazdo po zakończeniu jego używania, wywołując metodę close
.
Wysyłanie i Odbieranie Danych
Gdy masz już połączone gniazdo, możesz wysyłać i odbierać dane za pomocą różnych metod.
Metody Wysyłania Danych
send(bytes)
: Zwraca liczbę faktycznie wysłanych bajtów.sendall(bytes)
: Wysyła wszystkie dane, nawet jeśli wymaga to podzielenia na kilka kawałków. Nie działa dobrze z gniazdami nieblokującymi, dlatego preferowane jest użyciewrite
.write(buf)
: Próbuje zapisać cały bufor. Może to nie być możliwe z gniazdem nieblokującym. Zwraca liczbę faktycznie wysłanych bajtów.
Metody Odbierania Danych
recv(len)
: Zwraca nie więcej niżlen
bajtów jako obiektBytes
.recvfrom(len)
: Zwraca nie więcej niżlen
bajtów jako krotkę(bytes, address)
, gdzieaddress
to adres urządzenia wysyłającego dane.read(len)
: Zwraca nie więcej niżlen
bajtów jako obiektBytes
. Jeślilen
nie jest określone, czyta tyle danych, ile jest wysyłanych, aż gniazdo zostanie zamknięte.readinto(buf, len)
: Odczytuje nie więcej niżlen
bajtów dobuf
. Jeślilen
nie jest określone, używane jestlen(buf)
. Zwraca liczbę faktycznie odczytanych bajtów.readline()
: Odczytuje linię, kończącą się znakiem nowej linii.
Inne Metody
sendto(bytes, address)
: Wysyła dane do określonego adresu – używane gniazdo musi być niepołączone, aby to działało.makefile(mode='rb', buffering=0)
: Dostępne dla kompatybilności z Pythonem, który wymaga konwersji gniazda na plik przed odczytem lub zapisem. W MicroPython można to używać, ale nie ma to żadnego działania.
Klient HTTP z Gniazdem
Korzystając z tego, co już wiemy o gniazdach, możemy łatwo połączyć się z serwerem i wysyłać oraz odbierać dane. Jakie dane faktycznie wysyłamy i odbieramy, zależy od używanego protokołu. Serwery WWW używają HTTP, który jest bardzo prostym protokołem tekstowym. Korzystając z modułu urequests
, moglibyśmy ignorować naturę protokołu, ponieważ zaimplementował on większość dla nas. Kiedy używamy gniazd, musimy dokładnie określić, co wysłać.
Protokół HTTP to zasadniczo zestaw nagłówków tekstowych w formacie:
headername: headerdata \r\n
które informują serwer, co ma zrobić, oraz zestaw nagłówków, które serwer wysyła z powrotem, aby poinformować, co zrobił. Możesz sprawdzić szczegóły nagłówków HTTP w dokumentacji – jest ich sporo.
Najprostszą transakcją klient-serwer jest wysłanie żądania GET, aby serwer odesłał konkretny plik. Najprostszy nagłówek to:
"GET /index.html HTTP/1.1\r\n\r\n"
co jest żądaniem, aby serwer wysłał index.html
. W większości przypadków potrzebujemy jeszcze jednego nagłówka, HOST
, który podaje nazwę domeny serwera. Dlaczego? Ponieważ HTTP wymaga tego, a wiele stron internetowych jest hostowanych na jednym serwerze pod jednym adresem IP. To, z której strony serwer pobiera plik, zależy od nazwy domeny, którą określasz w nagłówku HOST.
Oznacza to, że najprostszy zestaw nagłówków, jakie możemy wysłać do serwera, to:
"GET /index.html HTTP/1.1\r\nHOST: example.org\r\n\r\n"
co odpowiada nagłówkom:
GET /index.html HTTP/1.1
HOST: example.org
Żądanie HTTP zawsze kończy się pustą linią. Jeśli jej nie wyślesz, większość serwerów nie odpowie. Dodatkowo nagłówek HOST musi mieć nazwę domeny bez dodatkowej składni – bez ukośników i bez http:
lub podobnych.
Przykład Klienta HTTP
request = b"GET /index.html HTTP/1.1\r\nHost: example.org\r\n\r\n"
Teraz jesteśmy gotowi wysłać nasze żądanie do serwera, ale najpierw potrzebujemy jego adresu i musimy połączyć gniazdo:
ai = socket.getaddrinfo("www.example.com", 80, socket.AF_INET)
addr = ai[0][-1]
s = socket.socket(socket.AF_INET)
s.connect(addr)
Teraz możemy wysłać nagłówki, które stanowią żądanie GET:
request = b"GET /index.html HTTP/1.1\r\nHost: example.org\r\n\r\n"
s.send(request)
Na koniec możemy poczekać na odpowiedź serwera i wyświetlić ją:
print(s.recv(512))
Zauważ, że wszystkie metody są blokujące w tym sensie, że nie zwracają wyniku, dopóki operacja nie zostanie zakończona.
Pełny program, z pominiętą funkcją konfiguracji podaną wcześniej, wygląda następująco:
import network
import socket
from machine import Pin, Timer
from time import sleep_ms
def setup(country, ssid, key):
# . . .
pass
wifi = setup(country, ssid, key)
print("Connected")
url = "http://192.168.253.72:8080"
ai = socket.getaddrinfo("www.example.com", 80, socket.AF_INET)
addr = ai[0][-1]
s = socket.socket(socket.AF_INET)
s.connect(addr)
request = b"GET /index.html HTTP/1.1\r\nHost: example.org\r\n\r\n"
s.send(request)
print(s.recv(512))
Widać, że moduł urequests
jest znacznie łatwiejszy w użyciu. Oczywiście, korzysta z gniazd do wykonania swojej pracy.
Klient HTTPS oparty na gniazdach SSL w MicroPython
Wprowadzenie
Wiele stron internetowych odmawia teraz obsługiwania niezaszyfrowanych danych i wymaga użycia HTTPS. Na szczęście stworzenie klienta HTTPS jest bardzo proste, ponieważ nie musisz tworzyć certyfikatu cyfrowego. Jeśli musisz udowodnić serwerowi, że to naprawdę ty próbujesz się połączyć, powinieneś zainstalować nowy certyfikat i go użyć. Procedura ta jest taka sama jak instalowanie nowego certyfikatu serwera, co omówimy później.
MicroPython ma bardzo minimalną implementację modułu ssl
z Pythona, ale jest ona wystarczająca do stworzenia klienta lub serwera HTTPS. Posiada tylko jedną funkcję wrap_socket
, która dodaje szyfrowanie, a w niektórych przypadkach uwierzytelnianie, do istniejącego gniazda. Obecnie MicroPython dla ESP32 nie obsługuje uwierzytelniania.
Funkcja wrap_socket
Funkcja wrap_socket
wygląda następująco:
ssl.wrap_socket(sock, keyfile=None, certfile=None,
server_side=False, cert_reqs=ssl.CERT_NONE,
ca_certs=None, do_handshake_on_connect=True)
Funkcja ta przyjmuje istniejące gniazdo określone przez sock
i przekształca je w zaszyfrowane gniazdo SSL. Oprócz sock
, wszystkie jej parametry, w tym keyfile
i certfile
, które określają pliki zawierające certyfikat i/lub klucz, są opcjonalne.
Wszystkie certyfikaty muszą być w formacie PEM (Privacy-Enhanced Mail). Jeśli klucz jest przechowywany w certyfikacie, wystarczy użyć tylko certfile
. Jeśli klucz jest przechowywany oddzielnie od certyfikatu, potrzebne są zarówno certfile
, jak i keyfile
. Parametr server_side
ustawia zachowanie odpowiednie dla serwera, gdy jest True
, a dla klienta, gdy jest False
. cert_reqs
określa poziom sprawdzania certyfikatu, od CERT_NONE
, CERT_OPTIONAL
do CERT_REQUIRED
. Ważność certyfikatu jest sprawdzana tylko, gdy wybierzesz CERT_REQUIRED
, ale obecnie ESP32 nigdy nie sprawdza certyfikatu pod kątem ważności. ca_certs
to obiekt typu bytes
zawierający certyfikat, który ma być użyty do walidacji certyfikatu klienta. server_hostname
jest używany do ustawienia nazwy hosta serwera, aby certyfikat mógł zostać sprawdzony i potwierdzony, że należy do danej strony oraz aby serwer mógł przedstawić odpowiedni certyfikat, jeśli hostuje wiele stron. do_handshake
powinno być używane z gniazdami nieblokującymi i gdy jest True
, opóźnia handshake, natomiast gdy False
, funkcja nie zwraca, dopóki handshake nie zostanie zakończony.
Zauważ, że nie wszystkie parametry pełnego Pythona wrap_socket
są obsługiwane i nie wszystkie poziomy TLS i szyfrowania działają. Oznacza to, że obecnie połączenie z niektórymi stronami może być trudne lub niemożliwe. Jednak w wielu przypadkach powinno to po prostu działać, o ile strona nie używa najnowszej implementacji.
Przykład Klienta HTTPS
Jedyna modyfikacja, jaką poprzedni klient HTTP potrzebuje, aby działać z witryną HTTPS, to:
import network
import socket
import ssl
from machine import Pin, Timer
from time import sleep_ms
def setup(country, ssid, key):
# ... konfiguracja WiFi ...
pass
wifi = setup(country, ssid, key)
print("Connected")
ai = socket.getaddrinfo("example.com", 443, socket.AF_INET)
addr = ai[0][-1]
s = socket.socket(socket.AF_INET)
s.connect(addr)
sslSock = ssl.wrap_socket(s)
request = b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"
sslSock.write(request)
print(sslSock.read(1024))
Zauważ, że wystarczy wykonać domyślną funkcję wrap_socket
po nawiązaniu połączenia. Pobrany HTML jest teraz pobierany za pomocą HTTPS. sslSock
zwrócony przez funkcję wrap_socket
ma tylko metody strumieniowego odczytu i zapisu.
Podsumowanie
Jeśli chcesz zrobić coś więcej niż HTTP lub chcesz zaimplementować serwer HTTP, musisz używać gniazd.
Gniazda są całkowicie ogólne i możesz używać ich do implementacji klienta lub serwera HTTP.
Aby umożliwić różne typy połączeń, gniazda mogą być używane z różnymi typami adresów. W przypadku ESP32 potrzebujemy jednak tylko używać adresów IP.
Klient gniazd musi obsługiwać szczegóły przesyłanych danych. W szczególności musisz obsługiwać szczegóły nagłówków HTTP.
Implementacja serwera gniazd jest łatwa, ale może być trudna do zapewnienia obsługi zarówno klientów, jak i wewnętrznych usług.
Najprostszym rozwiązaniem problemu serwera jest implementacja pętli pollingowej i do tego musisz używać gniazd nieblokujących.
Proste gniazda pracują z niezaszyfrowanymi danymi. Jeśli chcesz używać szyfrowania, musisz „opakować” gniazdo za pomocą modułu ssl
.
Klienci HTTPS nie potrzebują certyfikatu do implementacji szyfrowania, ale serwery tak.
Do testowania możesz wygenerować własne „samopodpisane” certyfikaty, chociaż większość przeglądarek będzie narzekać, że nie są one bezpieczne.
Kolejka połączeń pozwala obsłużyć więcej niż jedno połączenie klienta na raz.