Kurs esp32/esp8266 | #14 – Połączenie UDP/TCP (client, server)

Tekst zgodny z esp-idf 5.5.1+

ESP8266 RTOS SDK
W przypadku środowiska dla esp8266 poniższe przykłady będą działać tak samo.


UDP oraz TCP są podstawowymi protokołami transportowymi służącymi do wymiany danych w sieciach komputerowych na trasie klient-serwer. Można powiedzieć, że stanowią one fundament komunikacji w Internecie – praktycznie wszystkie protokoły wyższego poziomu (takie jak HTTP, MQTT, DNS i inne) opierają się na jednym z nich.

W systemach informatycznych realizacja tej komunikacji odbywa się za pomocą tzw. gniazd sieciowych (ang. sockets). Sockety to interfejsy programistyczne umożliwiające przesyłanie danych między procesami – zarówno pomiędzy różnymi programami uruchomionymi na tym samym urządzeniu, jak i pomiędzy komputerami w sieci.
To od programisty i wymogów aplikacji zależy, który z protokołów zostanie użyty – TCP, zapewniający niezawodność i uporządkowanie danych, czy UDP, który stawia na prostotę i szybkość, kosztem niezawodności.

W tej części kursu programowania mirkokontrolerów esp32 (esp-idf) i esp8266 dowiesz się jak owe protokoły skonfigurować do pracy.

lwIP

Środowiska programstyczne dla esp8266, jak i esp32 korzystają z popularnej otwarto-źródłowej implementacji stosu tcp/ip „lwip” (lightweight IP). Jest ona nieco zmieniona, aczkolwiek w większości zagadnień można opierać się o dokumentację lwip.

Programowanie sieciowe

Ten wpis jest tylko wprowadzeniem do programowania sieciowego na potrzeby pracy z układami esp. Zachęcam do dalszego zglębienia tematu. Materiałów na temat programowania sieciowego jest akurat całkiem sporo i są to bardzo uniwersalne zagadnienia niezależnie od używanego środowiska/języka prorgamowania.

TCP vs UDP

Gdy w naszym urządzeniu będziemy decydowali o doborze odpowiedniego protokołu do wymiany danych po sieci, musimy wiedzieć czym tak właściwie te dwa standardy się różnią.

tcp vs udp


Protokół TCP wymaga zestawienia połączenia między klientem, a serwerem. Przesyłane dane są uszeregowane (wszystkie dochodzą w takiej kolejności, w jakiej zostały wysłane), klient, lub serwer oczekuje potwierdzenia odebrania każdej paczki danych, a jeżeli takiego nie otrzyma, wysyła paczkę ponownie.

W przypadku UDP takie rzeczy nie występują. Protokół UDP wysyła paczkę danych nie nawiązując przed tym połączenia z serwerem (wyśle nawet gdy serwer będzie offline), nie oczekuje potwierdzeń, ani nie dba o porządek danych. Podobnie jednak jak tcp, sprawdza ich poprawność.

Te różnice powodują, że dobór odpowiedniego sposobu komunikacji jest kluczowy dla oczekiwanego działania urządzenia/aplikacji.
Przez fakt, iż UDP nie wysyła żadnych flag potwierdzeń, sekwencji etc. cała ramka zajmuje znacznie mniej pamięci. Mniej danych do przetworzenia = mniej % zajętości procesora, szybsza transmisja. UDP użyjemy wszędzie tam gdzie zależy nam na jak najszybszej transmisji, gdzie uporządkowanie danych nie robi dla nas aż takiego problemu, a więc i w mniej krytycznych zastosowaniach.
Na ten przykład transmisja na żywo zapisu kamerki, lub mikrofonu podłączonych do naszego mikrokontrolera. Prawda? Jeżeli jakieś dane do nas nie dotrą, lub dotrą ale w innej kolejności to po prostu w zapisie będzie zauważalna jakaś anomalia, która nie wpłynie jednak na całość przesłanej informacji.
Ponadto w przytoczonym przykładzie z transmisją z kamerki, w przypadku użycia protokołu tcp cała obsługa programowa takiego urządzenia będzie bardziej wymagająca. Musimy bowiem założyć przypadek gdy sieć będzie zajęta i w naszej przestrzeni buforowej zalegać będą oczekujace na przesłanie ramki (gdy cały czas dochodzą kolejne). Łatwo wtedy o różnego rodzaju „krasze”.

Jeżeli z kolei będziemy z/do układu przesyłać ważniejsze i podatniejsze na „korupcje” dane, niech to będzie implementacja sekwencji przeprogramowania urządzenia OTA (Over-The-Air). A więc wysyłamy nowy firmware do urządzenia, esp sobie go najpierw zapisuje na wydzielonej przestrzeni pamięci, a kolejno dokonuje zapisu do pamięci programowej.
W tym przypadku integralność odebranych danych jest turbo istotna. Jeżeli w trakcie przesyłu coś by się zagubiło, lub kolejność danych została by zaburzona, skutkować by to mogło „ucegleniem” urządzenia.


UDP client

Zaczniemy od zaprogramowania esp32 do pracy w trybie klienta udp. Serwer UDP uruchamiam prostą jego implementacją w pythonie.
Jedyne zadanie jakie ma serwer do wykonania to odbiór danych i wyświetlenie ich w oknie terminala, wraz z markerem czasowym. Poniżej kod skryptu.

Kod nr. 1

import socket
from datetime import datetime

HOST = "192.168.0.243" // Adres IP komputera
PORT = 9999

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((HOST, PORT))
print(f"Serwer UDP nasłuchuje na {HOST}:{PORT}")

try:
    while True:
        czas = datetime.now().strftime("%H:%M:%S")
        data, addr = sock.recvfrom(4096)
        print(f"{czas}|    {addr[0]}: {data.decode(errors='ignore')}")
        
        sock.sendto("OK, wszystko gra.".encode(), addr)

except KeyboardInterrupt:
    print("Zatrzymano serwer")
finally:
    sock.close()

Przejdźmy więc do konfiguracji samego esp32.

Kod nr. 2

#include "esp_netif.h"
#include "esp_wifi.h"
#include "nvs.h"
#include "nvs_flash.h"
#include "esp_log.h"

#include "lwip/err.h"
#include "lwip/sockets.h"
#include "lwip/sys.h"
#include <lwip/netdb.h>
#include <sys/_timeval.h>


#define SSID "SSID"
#define PASSWD "HASŁO"

#define SERVER_IP "192.168.0.243"
#define SERVER_PORT 9999
#define MSG "MCINM - esp32 udp client"

Powyżej niezbędne do dołączenia moduły, a także stałe zawierające informacje o sieci wifi i serwerze.

A propos jeszcze wifi, w tych przykładach zaraz za funkcją inicjalizującą wifi dodaję 5-sekundowy delay. Robię to by komponent wifi zdążył się połączyć i otrzymać IP. W realnym zastosowaniu oczywiście skorzystamy z Event Handlingu, konkretnie – „IP_EVENT_STA_GOT_IP”. Więcej przeczytasz o tym zagadnieniu w części dotyczącej wifi 😉 .

Kolejno powołuję strukturę typu „sockaddr_in” zawartą w ramach biblioteki lwip. Przechowuje ona konfigurację połączenia tj. od góry – Adres serwera, Definicję typu adresu IP (AF_INET=IPv4), PORT serwera.
Dalej tworzę socket, który będzie naszym interfejsem komunikacyjnym. Przekazujemy mu, znowu typ adresów, typ socketa (DGRAM = Datagram czyli UDP) i typ protokołu. Dalej znajduje się if, który zresetuje mikrokontroler gdy nie uda się stworzyć socketa.
Potem ustawiam wartość timeout dla funkcji „recvfrom” .

Kod nr. 3

struct sockaddr_in conn_config;
    
conn_config.sin_addr.s_addr = inet_addr(SERVER_IP);
conn_config.sin_family = AF_INET;
conn_config.sin_port = htons(SERVER_PORT);

int s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (s < 0 ) {
  ESP_LOGE(TAG, "Nie mozna stworzyc socketa: %d", errno);
  esp_restart();
}

// Ustawiamy timeout 2.5 sekundy
struct timeval timeout;
timeout.tv_sec = 2;
timeout.tv_usec = 500000;
   
if (setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)) < 0) {
  ESP_LOGE(TAG, "Nie mozna ustawic timeout'u: %d", errno);
}

Pozostaje wysłać wiadomość. Całość poniżej umieściłem już zwyczajnie w pętli głównej programu, i jest to prosta sekwencja wysyłania co 3 sekundy tekstu. Używamy w tym celu funkcji sendto (sendto to funkcja bezpołączeniowa, używana dla UDP), której przekazujemy (kolejno) nasz socket, wiadomość, długość wiadomości, dodatkowe flagi (u nas – brak) i na końcu strukturę konfiguracyjną lwip i jej wielkość.

Kod nr. 4

while(1) {
  int err = sendto(s, MSG, strlen(MSG), 0, (struct sockaddr *)&conn_config, sizeof(conn_config));
  if (err < 0) {
    ESP_LOGE(TAG, "Problem z wyslaniem: %d", errno);
  }
  else {
    ESP_LOGI(TAG, "Wyslano: %s", MSG);
  }
  	
   vTaskDelay(3000 / portTICK_PERIOD_MS);
 }

Po załadowaniu kodu do esp32, w oknie terminala z uruchomionym skryptem pythona można zauważyć nadchodzące dane z esp.

esp32 udp client

Poniżej link do githuba gdzie znajduje się cały blok kodu wzbogacony o funkcjonalność odbioru odpowiedzi z serwera. Odpowiedź zostaje zapisana do zdefiniowanego buforu, następnie proste porównanie, czy przyszło to co miało przyjść i wysłanie po serialu zwięzłego podsumowania.

https://github.com/mcinm/esp-idf/blob/main/kurs14/udp_client.c


UDP Server

Konfiguracja esp do pracy w trybie serwera UDP niewiele się różni od tej dla klienta. Na samym początku definiujemy strukturę jak wcześniej, ale z lekkimi modyfikacjami. Jako adres serwera podaję „INADDR_ANY” co generalnie oznacza, że nie bindujemy socketa sztywno, do żadnego konkretnego adresu IP, a do każdego dostępnego. W naszym przypadku i tak mamy tylko jeden adres, więc nie ma to najmniejszego znaczenia. Aczkolwiek możnaby zastosować zapis: inet_addr(„192.168.0.101”) jeżeli taki sam ustawię w konfiguracji połączenia z siecią wifi. Tutaj dodałem także bufor na adres klientów „client_addr”.

Dalej podobnie, AF_INET, czyli IPv4. Ustawiamy port, funkcja htons (host-to-network short) zamienia kolejność bajtów (w networkingu dominuje zapis big-endian).
Kolejno należy utworzyć socket i ustawić wartość czasu timeout’u. Tutaj ustawiłem ją na aż 15 sekund. Funkcją bind przypisujemy gniazdu adres IP, oraz port, według tych jakie zawarliśmy w strukturze „serverAddress”.

Na sam koniec pozostaje definicja struktury, do której wpadać będą informacje o adresie klientów.

Kod nr. 5

    char bufor_rx[128];
    char client_addr[128];
    struct sockaddr_in serverAddress;
    serverAddress.sin_addr.s_addr = INADDR_ANY;
    serverAddress.sin_family = AF_INET;
    serverAddress.sin_port = htons(SERVER_PORT);

    int s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);

    if (s < 0 ) {
        ESP_LOGE(TAG, "Nie mozna stworzyc socketa: errno %d", errno);
        esp_restart();
    }

    // Ustawiamy timeout 15 sekund
    struct timeval timeout;
    timeout.tv_sec = 15;
    timeout.tv_usec = 0;

    setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
    
    int err = bind(s, (struct sockaddr*)&serverAddress, sizeof(serverAddress));
    if (err < 0) {
        ESP_LOGE(TAG, "Nie udało się zbindować socketa: errno %d", errno);
	}
    ESP_LOGI(TAG, "Socket zbindowany na porcie: %d", SERVER_PORT);

    struct sockaddr_storage source_addr;
    socklen_t socklen = sizeof(source_addr);

Cały kod w poniższym linku. Tutaj pozwoliłem sobie całość obsługi naszego serwera UDP umieścić w funkcji głównej (app_main). W realnym zastosowaniu gdzie mikrokontroler robiłby coś jeszcze poza samym nasłuchem, zawarłbym całość związaną z połączeniem w ramach jednego taska freertos.

https://github.com/mcinm/esp-idf/blob/main/kurs14/udp_server.c

Po sflashowaniu esp, ten wystawi do nasłuchu port 12345, oczekiwać będzie 15 sekund (nasz timeout), jeżeli nic przez ten czas nie nadejdzie wyświetli error, jeżeli nadejdzie – jak poniżej.

Dane wysyłam z komputera z pomocą programu netcat:

echo "MCINM" | nc -w1 -u 192.168.0.100 12345

TCP client

W przypadku transmisji TCP, sama programowa obsługa nie będzie się jakoś znacznie różnić. Oczywiście inaczej nieco wyglądać będzie konfiguracja gniazda, także skorzystamy z innych funkcji do wysyłania/odbioru danych. Poprzednio było to „sendto”, oraz „recvfrom”; teraz po prostu „send” i „recv”. Protokół TCP wymaga najpierw zestawienia połączenia miedzy dwoma klientami, także logicznie – w ramach tego połączenia następować będzie wymiana. Co za tym idzie, nie potrzebujemy dodatkowo precyzować adresów przy wysyłce/odbiorze.
Ponadto pojawią się funkcję związane z obsługą połączenia, czyli funkcja „connect”, „close” i „shutdown”.

Zacznijmy od uruchomienia serwera TCP na komputerze.

Kod nr. 6

import socket
from datetime import datetime

HOST = "192.168.0.243"
PORT = 9999

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind((HOST, PORT))
print(f"Serwer TCP nasłuchuje na {HOST}:{PORT}")

sock.listen()
conn, addr = sock.accept()
print(f"Połączono z {addr}")
try:
    while True:
        czas = datetime.now().strftime("%H:%M:%S")
        data = conn.recv(4096)
        print(f"{czas}|    {addr}: {data.decode(errors='ignore')}")

        conn.send("OK, wszystko gra.\n".encode())
        
except KeyboardInterrupt:
    print("Zatrzymano serwer")
finally:
    conn.close()

Struktura przechowująca dane serwera nie ulega modyfikacjom. W funkcji socket zmieniamy wartości (SOCK_STREAM – strumień – TCP). Po utworzeniu socketa, łączymy się z naszym serwerem podając strukturę dest_addr.

Kod nr. 7

void app_main() {
    wifi_init();
    printf("Test esp32 TCP client - MCINM\n");
    sleep(5); // Dajmy czas na nawiązanie połączenia z wifi

    // Bufor
    char rx_buffer[128];

    struct sockaddr_in dest_addr;
    dest_addr.sin_addr.s_addr = inet_addr(SERVER_IP);
    dest_addr.sin_family = AF_INET;
    dest_addr.sin_port = htons(SERVER_PORT);
    
    // Tworzenie gniazda
    int s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (s < 0 ) {
        ESP_LOGE(TAG, "Nie mozna stworzyc socketa: %d", errno);
        esp_restart();
    }
    
    ESP_LOGI(TAG, "Socket utworzony, łączenie z %s:%d", SERVER_IP, SERVER_PORT);
    

    // Łączenie z serwerem
    int err = connect(s, (struct sockaddr *)&dest_addr, sizeof(dest_addr));
    if (err != 0) {
            ESP_LOGE(TAG, "Socket nie może się połączyć: errno %d", errno);
            esp_restart();
    }

W pętli głównej programu podobnież niewiele zmian w porównaniu z przykładem UDP. Możemy jedynie zauważyć tutaj wspomniane wcześniej nowe funkcje, close, oraz shutdown umieściłem w sekcji, która wystąpi przy błędzie wysłania danych do serwera.
W praktyce, gdy serwer zostanie zamknięty funkcja send się nie wykona, esp zwolni gniazdo. Korzystaj z funkcji „close” i „shutdown” by prawidłowo obsługiwać połączenie TCP. Nagłe zerwanie połączenia może powodować niechciane komplikacje.
Cały kod poniżej…

https://github.com/mcinm/esp-idf/blob/main/kurs14/tcp_client.c


TCP server

Kod nr. 8

static void do_retransmit(const int s)
{
    int len;
    char rx_buffer[128];

    do {
        len = recv(s, rx_buffer, sizeof(rx_buffer) - 1, 0);
        if (len < 0) {
            ESP_LOGE(TAG, "Nie udało się odebrać wiadomości: errno %d", errno);
        } else if (len == 0) {
            ESP_LOGW(TAG, "Połączenie zakończone");
        } else {
            rx_buffer[len] = 0;
            ESP_LOGI(TAG, "Odebrano %d bajtów: %s", len, rx_buffer);
        }
    } while (len > 0);
    static char payload[] = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 19\r\n\r\nMCINM - Hello World";
    send(s, payload, sizeof(payload), 0);
}




void app_main(void)
{
    wifi_init();
    printf("Test esp32 TCP client - MCINM\n");
    sleep(5); // Dajmy czas na nawiązanie połączenia z wifi

    // Bufor na adres klienta
    char addr_str[128];
    struct sockaddr_storage dest_addr;
    int keepAlive = 1; // true - włączamy procedurę keepalive
    int keepIdle = 5; // Czas bezczynności w sekundach po jakim server zacznie wysyłać okresowe pakiety sprawdzające czy client jest osiągalny 
    int keepInterval = 5; // Czas w sekundach między pakietami sprawdzającymi
    int keepCount = 3; // Ilość pakietów sprawdzających po których (bez odpowiedzi) server porzuci połączenie
	
    struct sockaddr_in *dest_addr_ip4 = (struct sockaddr_in *)&dest_addr;
    dest_addr_ip4->sin_addr.s_addr = htonl(INADDR_ANY);
    dest_addr_ip4->sin_family = AF_INET;
    dest_addr_ip4->sin_port = htons(SERVER_PORT);
    
    int listen_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (listen_sock < 0) {
        ESP_LOGE(TAG, "Nie udało się utworzyć socketa: errno %d", errno);
        return;
    }

    int opt = 1;
    setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));


    int err = bind(listen_sock, (struct sockaddr *)&dest_addr, sizeof(dest_addr));
    if (err != 0) {
        ESP_LOGE(TAG, "Nie udało się zbindować socketa: errno %d", errno);
    }

    err = listen(listen_sock, 1);
        if (err != 0) {
            ESP_LOGE(TAG, "Wystąpił problem przy próbie nasłuchu socketa: errno %d", errno);
         }




    while (true) {
        sleep(5);
        printf("Kolejna pętla\n");

        struct sockaddr_storage source_addr;
        socklen_t addr_len = sizeof(source_addr);
        int s = accept(listen_sock, (struct sockaddr *)&source_addr, &addr_len);
        if (s < 0) {
            ESP_LOGE(TAG, "Nie udało się zaakceptować połączenia: errno %d", errno);
        }

        setsockopt(s, SOL_SOCKET, SO_KEEPALIVE, &keepAlive, sizeof(int));
        setsockopt(s, IPPROTO_TCP, TCP_KEEPIDLE, &keepIdle, sizeof(int));
        setsockopt(s, IPPROTO_TCP, TCP_KEEPINTVL, &keepInterval, sizeof(int));
        setsockopt(s, IPPROTO_TCP, TCP_KEEPCNT, &keepCount, sizeof(int));

        do_retransmit(s);

        shutdown(s, 0);
        close(s);
    }
}

No… Tutaj dzieje się już nieco więcej. Na samym początku konfiguracji pojawiło się sporo zmiennych dotyczących procedury KEEPALIVE. Te opcje są ustawiane oczywiście funkcją „setsockopt”.
Jak można jednak zauważyć sekcja tych ustawień znajduje się w pętli głównej, to dlatego że ustawiamy je za każdym razem gdy ustanowione zostaje nowe połączenie z klientem.
Przed pętlą główną konfigurowany jest socket nasłuchujący i w trybie nasłuchu on pozostaje cały czas. Natomiast funkcja accept, która akceptuje połączenie, tworzy swój własny socket, który służy do komunikacji w ramach tego jednego połączenia klient-serwer.

Ustawiam tutaj także opcję „SO_REUSEADDR”, umożliwia ona wywołanie funkcji „bind” na porcie, który był wcześniej używany (ale gniazdo zostało zamknięte) i może być jeszcze w stanie TIME_WAIT.

Uchwyt tego socketa jest przekazywany później do funkcji „do_retransmit”, a tam w ramach pętli do-while jest odczytywana wiadomość. Pętla do-while użyta została w razie gdyby nadchodzące dane wykraczały poza długość przygotowanego bufora.
Na sam koniec serwer wysyła odpowiedź, przerobiłem ten przykład tak by wysyłał prostą odpowiedź http, bo przecież protokół http działa na TCP.

Po odebraniu wiadomości, serwer wysyła odpowiedź, po czym wraca do pętli głównej gdzie następuje zamknięcie połączenia.

Cały kod: https://github.com/mcinm/esp-idf/blob/main/kurs14/tcp_server.c

Zamieść komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *