Socket UDP/IP

 ☗ 

SOCKET

Un socket è una connessione tra il proprio programma e la rete IP esterna. Tramite i socket due o più programmmi (scritti in un qualsiasi linguaggio) possono scambiarsi dei dati senza necessità di sapere cosa ci sia al di là dei socket stessi. I programmi infatti possono essere in esecuzione sullo stesso computer, su computer diversi di una rete LAN locale, oppure su computer geograficamente distanti collegati tramite internet. I computer possono anche avere sistemi operativi differenti, o utilizzare tipologie di LAN differenti (ad esempio uno ethernet e l'altro token ring), ma tutti questi dettagli sono "nascosti" alle applicazioni, che non devono fare altro che trasmettere o ricevere dati attraverso il socket.

Più precisamente i socket sono l'interfaccia di livello più basso tra il proprio programma e i protocolli (TCP e UDP) del livello di trasporto dati della rete IP. In sostanza tramite i socket consegnamo un messaggio ai protocolli di trasporto che, veicolati e instradati dall'IP sottostante, lo recapitano a destinazione.

Il sistema di trasporto più semplice è l'UDP, è sufficiente consegnargli i dati e indicargli l'indirizzo IP e la porta di ricezione del destinatario. A questo punto i dati vengono trasportati fino a destinazione, dove, in ricezione a quell'indirizzo e su quella porta, dovrà esserci il socket del programma destinatario.

L'UDP è un protocollo semplice, è come imbucare una lettera e lasciare che le poste, se tutto va bene, la recapitino al destinatario. Se invece qualcosa va storto, la lettera viene semplicemente persa (in nessun caso viene rispedita al mittente). I messaggi possono anche essere spediti a destinatari inesistenti, in questo caso vengono persi, il mittente non ha alcun modo di conoscere l'esito dell'operazione se non ricevendo un messaggio di conferma da parte del destinatario.

Questo può sembrare uno svantaggio, ma nella sua semplicità l'UDP è molto veloce, e all'interno di una piccola LAN locale probabilmente è rara la perdita di messaggi. I programmi che comunicano attraverso questo sistema devono però essere strutturati in modo da far fronte a questa eventualità.

Se invece serve un trasporto "sicuro", con controllo degli errori, ritrasmissione automatica in caso di dati non corretti, e con garanzia che alla fine tutto quello che si è spedito è giunto integro a destinazione, allora si deve usare il TCP.


CLIENT E SERVER

Generalmente un programma che tiene un socket aperto in ascolto (listening) su un indirizzo e porta ben precisi si chiama server.

Viceversa un programma che apre un socket e contatta un server si chiama client.

Un server deve sempre associare (binding) al proprio socket un indirizzo preciso, che deve essere ovviamente conosciuto da ciascun client che lo voglia contattare.
Invece un client è obbligato a conoscere solo l'indirizzo da chiamare, l'indirizzo del proprio socket, se non sottoposto a binding, viene fornito dal sistema operativo.
Un server riceve comunque l'indirizzo di chi lo sta contattando, per cui è in grado di rispondergli.
Più in generale quando da un socket arrivano dei dati, assieme ad essi arriva anche l'indirizzo IP di chi li ha mandati.


I DATI TRASMESSI E RICEVUTI

Tipo di dati trasmessi e ricevuti

I dati trasferiti sono fondamentalmente delle sequenze di byte lunghe al massimo quanto impostato come grandezza del buffer di ricezione. Se ad un socket UDP arriva una sequenza più lunga del suo buffer, il socket stesso va in errore e i dati sono persi.

Inoltre se ad un socket arrivano più messaggi (datagrammi) senza che i precedenti siano stati acquisiti dal programma destinatario, i messaggi si accodano e vanno recuperati ciascuno con una successiva lettura.

Il contenuto delle sequenze binarie ha senso solo per i programmi mittente e destinatario, ai socket non interessa cosa viene trasmesso, allo stesso modo ai programmi non interessa sapere come fanno i socket a vedersi tra di loro. L'unica cosa che conta è che entrambi i programmi si appoggino allo stesso tipo di "trasportatore", TCP o UDP.



IN PRATICA

Vogliamo trasmettere delle stringhe di caratteri ASCII da un programma client ad un programma server tramite protocollo UDP/IP, il server deve stare in ascolto (listening) all'indirizzo 192.168.1.22 porta 5000.
Il server deve rimandare indietro la stringa al mittente (eco) e stampare cosa ha ricevuto. Inoltre se la stringa è uguale a "stop server" deve terminare la sua esecuzione.
Il client deve trasmettere le stringhe, ricevere l'eco e stamparlo, e alla fine inviare la stringa "stop server" per arrestare il server stesso.

Creiamo il socket UDP del client dandogli il nome cli:

import socket
cli = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

Con un ciclo for trasmettiamo in formato stringa i numeri da 1 a 10, e riceviamo e stampiamo l'eco di ritorno:

for h in range(10):
    stringa = str(h+1)
    cli.sendto(stringa.encode("ascii"), ("192.168.1.22", 5000))
    dati = cli.recv(8192)
    print("Eco: " + dati.decode("ascii"))  

Ed infine diamo lo stop al server e chiudiamo il nostro socket:

cli.sendto("stop server".encode("ascii"), ("192.168.1.22", 5000))
cli.close()  

Sul lato server facciamo più o meno la stessa cosa, creiamo un socket UDP di nome ser e lo associamo (binding) ad un indirizzo ben preciso:

import socket
ser = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
ser.bind("192.168.1.22", 5000)

A questo punto con un ciclo ci mettiamo in ascolto, stampiamo i messaggi ricevuti e li rispediamo al mittente. Se il messaggio corrisponde a "stop server" allora terminiamo l'esecuzione del ciclo con break e chiudiamo il socket:

while True:
    dati, addr = ser.recvfrom(8192)
    print("Ricevuto: " + dati.decode("ascii"))
    cli.sendto(dati, addr)
    if dati.decode("ascii") == "stop server":  break    
ser.close()

Come si può vedere nel server usiamo recvfrom invece di recv per ottenere in addr anche l'indirizzo del mittente, quindi possiamo rispondergli.

Nel caso del client le uniche cose che possono arrivare sono le risposte del server contattato (nessun altro conosce l'indirizzo del client), mentre il server, avendo effettuato il bind con un indirizzo preciso, può essere chiamato da chiunque conosca quell'indirizzo, anche da più di un client (caso non gestito in questi esempi).

Il codice degli esempi appena visti è dimostrativo, in pratica vi sono alcuni probemi, il primo è che le letture sono "bloccanti", cioè il programma ogni volta che incontra una recvfrom() si ferma e attende indefinitmente l'arrivo di un datagramma.
Questo può portare a situazioni di blocco dell'elaborazione (basta un pacchetto perso), e anche a non poter più usare un indirizzo IP nel caso il programma venga interrotto forzatamente.

Inoltre le istruzioni recvfrom() possono generare una serie di errori difficilmente prevedibili, che interromperebbero immediatamente l'esecuzione del programma, lasciando l'altro capo "appeso", in attesa di una risposta che non arriverà mai.
I programmi client e server visti prima potrebbero funzionare bene, se non si verifica nessun errore di comunicazione, ma potrebbero anche bloccarsi senza possibilità di fermarli.

Un altro errore che sembra verificarsi spesso è "connection reset by peer", ad esempio provando a leggere un socket subito dopo averlo usato per inviare dei dati. Questo si risolve aggiungendo un ritardo di qualche millisecondo dopo l'invio usando la funzione sleep del modulo time.


NON BLOCCHIAMO...

Un sistema per togliersi da ogni impiccio in modo semplice è quello di:


Il primo punto è il più semplice, prima dell'utilizzo del socket in lettura si imposta un timeout:

nomesocket.settimeout(10)

In questo caso, quando si chiama una recv/recvfrom, se ci sono dati vengono letti, altrimenti il socket attende per 10 secondi al massimo. Se il timeout scade viene sollevata un'eccezione di tipo socket.timeout. Passare 0 a settimeout fa si che la lettura non attenda, se ci sono dati vengono ritornati, altrimenti il socket va subito in errore. Passando invece Null il socket torna ad essere bloccante come di default.

Per la gestione degli errori può essere interessante creare una piccola funzione di nome sock_rd che fa una lettura, se va a buon fine ritorna dati e indirizzo del mittente, se va male per qualsiasi motivo (reset di connessione, datagrammi troppo grandi, dati non presenti) restituisce stringa nulla e indirizzo nullo.

def sock_rd(s):
    try:
        return s.recvfrom(LEN_UDP_BUFFER)
    except:
        return "".encode("ascii"), ("", 0)

Per una lettura con timeout variabili può invece essere comoda una funzione, chiamiamola sock_rdt, e dotiamola anche di un parametro facoltativo "timeout" in modo da poter eventualmente generare un timeout lungo a piacere:

def sock_rdt(s, timeout=1.0):
    s.settimeout(timeout)
    try:
        return s.recvfrom(LEN_UDP_BUFFER)
    except:
        return "".encode("ascii"), ("", 0)

E se a questo punto ci serve invece di nuovo una lettura bloccante? Può essere il caso del ciclo di attesa principale di un server che nel frattempo non deve fare altro. Allora basta semplicemente chiamare sock_rdt specificando timeout=Null

Ecco allora come conviene modificare i programmi client e server precedenti, per renderli "immuni" da errori di comunicazione di basso livello e blocchi in cicli infiniti:

#-----------------------------------------------------
# Esempio client UDP
#-----------------------------------------------------

import socket, time
REMOTE_ADDR = "192.168.1.22", 5000
LEN_UDP_BUFFER = 8192

#-----------------------------------------------------

def sock_rdt(s, timeout=1.0):
    s.settimeout(timeout)
    try:
        return s.recvfrom(LEN_UDP_BUFFER)
    except:
        return "".encode("ascii"), ("", 0)

#-----------------------------------------------------

cli = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
for h in range(10):
    cli.sendto(str(h+1).encode("ascii"), REMOTE_ADDR)
    time.sleep(0.005)
    dati, addr = sock_rdt(cli)
    if dati:
        print("Eco: " + dati.decode("ascii"))
    else:
        print("Timeout ricezione!")
cli.sendto("stop server".encode("ascii"), REMOTE_ADDR)
cli.close()
try: raw_input("...")
except NameError: input("...")
#-----------------------------------------------------
# Esempio server UDP
#-----------------------------------------------------

import socket
LOCAL_ADDR = "192.168.1.22", 5000
LEN_UDP_BUFFER = 8192

#-----------------------------------------------------

def sock_rd(s):
    try:
        return s.recvfrom(LEN_UDP_BUFFER)
    except:
        return "".encode("ascii"), ("", 0)

#-----------------------------------------------------

ser = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
ser.bind(LOCAL_ADDR)
while True:
    dati, addr = sock_rd(ser)
    if dati:
        s = "Da (%s:%s) Ricevuto: %s"
        print(s % (addr[0], addr[1], dati.decode("ascii")))
        ser.sendto(dati, addr)
    else:
        print("Errore ricezione!")
    if dati.decode("ascii") == "stop server":  break    
ser.close()
try: raw_input("...")
except NameError: input("...")


La ricezione del server è stata lasciata volutamente bloccante, cioè la funzione sock_rd termina solo quando arrivano effettivamente dei dati... o quando si verifica un errore, che in questo caso non crea alcun tipo di problema se non la stampa dell'avviso "Errore ricezione!". Quando arriva un messaggio viene stampato anche l'indirizzo del mittente. Nota: il programma server si può arrestare solo inviandogli la stringa "stop server".

L'indirizzo IP è stato scritto in una costante, come pure la dimensione del buffer di ricezione, in questo caso di 8 kbyte.

Qui sotto le finestre client (dietro) e server (in primo piano) al termine dell'esecuzione, i tre puntini sono una richiesta di input da tastiera per non far sparire subito le finestre in ambiente Windows.






Due classi per simulare tramite messaggi UDP/IP un dialogo su porta seriale (con gli stessi metodi di pyserial)


Con le due classi ClientConnector e ServerConnector, derivate da UDP_Connector, è possibile inviare in modo semplificato stringhe tra un'applicazione e l'altra, usando gli stessi metodi di pyserial, e quindi lavorare come se le applicazioni fossero collegate tra loro da porte seriali.

È mantenuto il concetto di applicazione client che interroga un'applicazione server, e il client deve conoscere esattamente l'indirizzo del server per poterlo raggiungere.

I metodi usabili sono riassunti nei commenti all'inizio. Una differenza con pyserial è che chiamando read() senza specificare la quantità di caratteri da leggere viene ritornato tutto, mentre in pyserial verrebbe ritornato un solo carattere come se si fosse scritto read(1).

Per inviare una stringa ASCII in giro per il mondo:

import UDP_Connector
cli = UDP_Connector.ClientConnector("206.190.60.37", 5555)
cli.write("hello yahoo".encode("ascii")))
cli.close()

In questo caso inviamo una stringa in un datagramma UDP verso un server di yahoo, ma naturalmente non possiamo aspettarci alcuna risposta in quanto non stiamo usando nessun protocollo da lui riconosciuto, probabilmente non avrà alcun socket in ascolto sulla porta 5555, anzi è ancora più probabile che il nostro datagramma venga piallato già prima dal loro firewall.
Se invece usassimo un socket TCP puntando la porta 80 e inviando una stringa correttamente formattata come richiesta HTTP, allora risponderebbe il web server inviandoci il contenuto HTML richiesto. Funzioni già pronte per compiere queste operazioni si trovano nei moduli urllib e urllib2, nel nostro caso ci limitiamo invece ad inviare delle semplici stringhe verso un apposito programma server. Ad esempio possiamo riprendere gli esempi client/server di prima e riscriverli usando queste classi:

#---------------------------------------------------------
# Esempio client che comunica in stile pyserial
#---------------------------------------------------------

import UDP_Connector
cli = UDP_Connector.ClientConnector("192.168.1.22", 5010)
for h in range(10):
    cli.write(str(h+1).encode("ascii"))
    dati = cli.read()
    if dati:
        print("Eco: " + dati.decode("ascii"))
    else:
        print("Timeout risposta!")
cli.write("stop server".encode("ascii"))
cli.close()
try: raw_input("...")
except NameError: input("...")
#---------------------------------------------------------
# Esempio server che comunica in stile pyserial
#---------------------------------------------------------

import UDP_Connector
ser = UDP_Connector.ServerConnector("192.168.1.22", 5010)
while True:
    dati = ser.read()
    if dati:
        print("Ricevuto: " + dati.decode("ascii"))
        ser.write(dati)
    if dati.decode("ascii") == "stop server":  break    
ser.close()
try: raw_input("...")
except NameError: input("...")
      


SOCKET SERVER

Il modulo socketserver permette di realizzare un server in modo ancora più semplice.

#-----------------------------------------------------
# Esempio server UDP
#-----------------------------------------------------

from socketserver import UDPServer, BaseRequestHandler
LOCAL_ADDR = "192.168.1.22", 5000

#-----------------------------------------------------

class Gest(BaseRequestHandler):

    def handle(self):
        sock = self.request[1]
        data = self.request[0]
        addr = self.client_address
        print(
            "From (%s:%s) Recieved: %s" %
            (addr[0], addr[1], data.decode("ascii"))
        )
        sock.sendto(data, addr)

#-----------------------------------------------------

server = UDPServer(LOCAL_ADDR, Gest)
server.serve_forever()


Subclassamento del server per renderlo stoppabile da comando:
#-----------------------------------------------------

from socketserver import UDPServer, BaseRequestHandler
LOCAL_ADDR = "192.168.1.22", 5000

#-----------------------------------------------------

class Handler(BaseRequestHandler):

    def handle(self):
        sock = self.request[1]
        data = self.request[0]
        addr = self.client_address
        print(
            "From (%s:%s) Recieved: %s" %
            (addr[0], addr[1], data.decode("ascii"))
        )
        sock.sendto(data, addr)
        if data.decode("ascii") == "stop server":
            self.server.stop_serving()

#-----------------------------------------------------

class StoppableUDPServer(UDPServer):

    def __init__(self, addr, handler):
        UDPServer.__init__(self, addr, handler)
        self.serving = True


    def serve_forever(self):
        # ridefinisce server_forever aggiungendo condizione di stop
        while self.serving:
            self.handle_request()


    def stop_serving(self):
        self.serving = False

#-----------------------------------------------------

server = StoppableUDPServer(LOCAL_ADDR, Handler)
server.serve_forever()


TELECONTROLLI


La comunicazione tramite socket apre le porte ai telecontrolli via internet, basta pensare ad una connessione ethernet/internet come ponte per creare un cavo seriale virtuale. O ancora, possiamo creare un server per simulare la presenza di un apparato in modo da testare il client, poi è sufficiente cambiare la classe da cconnector a pyserial e dialogare con l'apparato vero e proprio.

In ogni caso l'applicazione server deve trovarsi ad un indirizzo conoscibile e raggiungibile.
Per essere conoscibile o si appoggia ad una connessione con IP statico, oppure è necessario un "servizio ponte" che viene contattato periodicamente dall'applicazione stessa per rendere noto il suo IP. Il client deve chiamare questo servizio ponte che lo reindirizzerà all'IP del server.

Per essere raggiungibile occorre anche istruire il router/firewall indicandogli che le connessioni in arrivo verso una data porta possono passare e vanno reindirizzate verso un PC ben preciso della LAN locale (che ovviamente dovrà avere un indirizzo fisso all'interno della LAN).

Se interessano i telecontrolli non vanno dimenticati gli ethernet serial server, scatolotti che mettono a disposizione una o più porte seriali e che sono collegati ad una LAN, vengono configurati con un proprio IP / porta / protocollo, e possono essere contattati tramite UDP, TCP ecc.