Uno dei progetti più popolari tra quelli inclusi nel mio tutorial sul chip enc28j60 è sicuramente WebRelay. Tale progetto consente di attivare una uscita di Arduino tramite una semplice pagina web, accessibile anche da smartphone. Oggi vedremo come eseguire WebRelay con il chip esp32; sarà l’occasione per spiegarvi come realizzare un server TCP, in particolare un web server.
Netconn API
Come ormai sapete, il framework esp-idf utilizza la libreria lwip per gestire le comunicazioni di rete. Questa libreria offre diversi livelli di astrazione: il programmatore può decidere di gestire nel proprio programma i pacchetti grezzi (raw) oppure di utilizzare componenti già pronti.
Per realizzare il nostro server TCP, utilizzeremo proprio uno di questi componenti già pronti: le Netconn API.
Il suo utilizzo per realizzare un server è molto semplice ed è schematizzato nei seguenti passi:
Il metodo netconn_new() crea una nuova connessione, restituendo un puntatore a struct netconn che rappresenta la nuova connessione:
struct netconn *conn; conn = netconn_new(NETCONN_TCP); |
Il parametro passato al metodo indica il tipo di connessione… quelli più comuni sono NETCONN_TCP per una connessione con protocollo TCP e NETCONN_UDP per una con protocollo UDP.
Per utilizzare la connessione in modalità server dobbiamo quindi associarla (bind) ad una specifica porta… ad esempio un server web normalmente è in ascolto sulla porta 80 (443 se in HTTPS):
netconn_bind(conn, NULL, 80); |
Il secondo parametro del metodo (NULL sopra) consente di associare la connessione anche ad uno specifico indirizzo IP e può essere utile nel caso il dispositivo abbia più interfacce di rete. Utilizzando NULL (o l’equivalente IP_ADDR_ANY) si chiede alla libreria di effettuare il bind su ogni interfaccia disponibile.
Infine possiamo mettere il programma in ascolto con il metodo listen:
netconn_listen(conn); |
Gestiamo una nuova connessione
Utilizzando il metodo netconn_accept() il nostro programma può accettare una nuova connessione in ingresso:
struct netconn *newconn; netconn_accept(conn, &newconn); |
Il metodo restituisce un puntatore ad una nuova struct netconn che rappresenta la connessione stabilita con il client. Questo metodo è bloccante: il programma si ferma finché un client non effettua una richiesta di connessione.
Una volta stabilita la connessione, è possibile utilizzare i metodi netconn_recv() e netconn_write() per ricevere o inviare dati al client:
netconn_recv(struct netconn* aNetConn, struct netbuf** aNetBuf ); netconn_write(struct netconn* aNetConn, const void* aData, size_t aSize, u8_t aApiFlags); |
Il metodo netconn_recv(), per ottimizzare l’utilizzo della memoria RAM, gestisce i dati tramite un buffer interno (modalità zero-copy). Per poter accedere ai dati ricevuti è quindi necessario:
- dichiarare una variabile come puntatore a struct netbuf
- passare l’indirizzo di tale puntatore come secondo parametro
- utilizzare il metodo netbuf_data() per ottenere un puntatore ai dati all’interno del netbuffer e la loro lunghezza
struct netbuf *inbuf; char *buf; u16_t buflen; netconn_recv(conn, &inbuf); netbuf_data(inbuf, (void**)&buf, &buflen); |
Similmente il metodo netconn_write() accetta, come ultimo parametro, un flag per indicare se copiare o meno il contenuto del buffer prima di effettuare l’invio. Per risparmiare memoria è quindi possibile, se si ha la sicurezza che tale buffer non sia alterato da altri thread, indicare come flag NETCONN_NOCOPY:
netconn_write(conn, outbuff, sizeof(outbuff), NETCONN_NOCOPY); |
Al termine del dialogo con il client, la connessione può essere chiusa e il buffer liberato:
netconn_close(conn); netbuf_delete(inbuf); |
HTTP server
Quanto abbiamo visto finora, può essere applicato ad un qualsiasi server TCP. Se vogliamo dialogare con un browser Internet dobbiamo “parlare” la stessa lingua, ovvero il protocollo HTTP.
Nel programma di esempio (disponibile su Github) ho quindi implementato una versione minimale di tale protocollo. Quando digitiamo nel browser un indirizzo Internet (es. www.google.com), il browser si collega al server di Google e invia una richiesta nella forma
GET <risorsa> [...] |
La richiesta può avere diversi campi ma la prima riga contiene sempre il nome della risorsa (pagina, immagine…) che si vuole ottenere. In particolare se si accede alla homepage del sito, la richiesta sarà sempicemente GET /.
Il sito pubblicato da esp32 per controllare il relay è composto da solo due pagine:
- off.html, visualizzata quando il relay è spento
- on.html, visualizzata quando il relay è acceso
Ogni pagina contiene una scritta (“Relay is ON|OFF“) e una immagine. L’immagine contiene un link all’altra pagina e cliccandola viene anche cambiato lo stato del relay:
Il programma identifica la risorsa richiesta verificando il contenuto della richiesta con strstr():
char *first_line = strtok(buf, "\n"); if(strstr(first_line, "GET / ")) [...] else if(strstr(first_line, "GET /on.html ")) [...] else if(strstr(first_line, "GET /on.png ")) [...] |
Un server HTTP risponde al browser indicando per prima cosa l’esito della richiesta. Se è ok, il codice inviato è 200:
HTTP/1.1 200 OK |
quindi indica il media type della risorsa richiesta e infine invia la risorsa. In questo esempio, gli unici media type possibili sono:
- text/html per le pagine HTML
- image/png per le immagini
Contenuto statico
Un server web normalmente memorizza le risorse che compongono il sito pubblicato su un supporto esterno (memory card, disco fisso…). In casi molto semplici è possibile anche includere tutto il contenuto all’interno del programma.
In particolare nell’esempio proposto le pagine HTML e le stringhe di risposta del protocollo HTTP sono incluse come array statici:
const static char http_html_hdr[] = "HTTP/1.1 200 OK\nContent-type: text/html\n\n"; const static char http_png_hdr[] = "HTTP/1.1 200 OK\nContent-type: image/png\n\n"; const static char http_off_hml[] = ""; |
Per includere anche le immagini, ho sfruttato una funzionalità del framework (embedding binary data). E’ possibile indicare nel file component.mk i files da includere:
All’interno del programma è possibile accedere al contenuto dei files embedded tramite appositi puntatori:
extern const uint8_t on_png_start[] asm("_binary_on_png_start"); extern const uint8_t on_png_end[] asm("_binary_on_png_end"); extern const uint8_t off_png_start[] asm("_binary_off_png_start"); extern const uint8_t off_png_end[] asm("_binary_off_png_end"); |
la sintassi è sempre _binary_filename_start|end, sostituendo il “.” con “_” nel nome del file. Avendo a disposizione i due puntatori (start ed end) è facile inviare l’intero contenuto del file con il metodo netconn_write() già spiegato:
netconn_write(conn, on_png_start, on_png_end - on_png_start, NETCONN_NOCOPY); |
Demo
sottotitoli in italiano disponibili