Riconoscere un ESP8266 che si connette alla rete

Come può un dispositivo basato su un Esp8266 "avvvisare" gli altri dispositivi che si è registrato sulla rete?

Un network dinamico di dispositivi IoT

Il caso d'uso è molto semplice. Supponiamo di avere un modulo ESP01 connesso ad un attuatore tipo un relè che accende/spegne le luci di casa, un altro che attiva o disattiva l'irrigazione e altri che fanno chissà cosa. Il mio obbiettivo è avere una dashboard che:

  1. Elenchi i dispositivi connessi alla rete
  2. Tramite una pratica dashboard, per ogni dispositivo voglio comandare il relativo relè connesso al dispositivo stesso 
  3. Il mio network deve essere dinamico. Nel dettaglio voglio poter aggiungere o togliere dispositivi dalla rete molto semplicemente.

Il principio di funzionamento

Il sistema ha un server che si occupa di rilevare la presenza di un nuovo dispositivo e "registrarlo". Il server ospita la dashboard.

Parte ESP8266

Il dispositivo, basato sull' ESP-8266, necessita di una  prima configurazione per la selezione delle rete WiFi e la relativa password. Sarà implementata una piccola interfaccia web. Di fatto, all'avvio, l'ESP8266 ha due possibilità:

  1. Ha una rete WiFi preimpostata e riesce a connettersi
  2. Non ha una rete preimpostata oppure, ce l'ha ma non riesce a connettersi alla rete Wifi

Poi rimane in ascolto di eventuali comandi.

Nel secondo caso si avvia come web server ed espone una pagina web per la selezione della rete, l'immissione della password e il nome che voglio assegnargli per riconoscerlo tra tutti i dispositivi. La pagina web ha un tasto che consente il salvataggio di queste informazioni nella eprom dell' Esp8266. A questo punto il workflow si riavvia e ci ritroviamo nel primo caso.

Adesso abbiamo il nostro dispositivo IoT connesso alla rete. Nessuno (eccetto lui) conosce il suo IP o il suo nome (o magari le azioni che può compiere). Lui stesso non conosce l'IP del server. 

La soluzione a questo problema è molto semplice: inviare un pacchetto in broadcast per informare che il dispositivo c'è.

Per fare  un esempio, è come se si entrasse in una stanza piena di gente e si urlasse "Hey! Sono il dispositivo X. Se dovete contattarmi il mio ip è XXX.XXX.XXX.XXX". Per chi non lo sapesse, un pacchetto inviato in broadcast (ovvero all'ip 255.255.255) viene ricevuto da tutti i dispositivi connessi alla rete. Quindi anche dal server.

Workflow schema di principio

Il server

La parte server è un applicativo web .netcore che sta in ascolto e reagisce di conseguenze quando un dispositivo chiede di accedere alla rete. Come primo step, il server, censirà i dispositivi connessi visualizzando nome, ip, un tasto e data/ora di connessione.

Il progetto

Come anticipato per il dispositivo ho deciso di utilizzare modulo ESP01 (basato sul chip esp8266) per gestire la comunicazione in rete WiFi. Per sapere cosa serve per iniziare a sviluppare sull'ESP8266 puoi fare riferimento all'articolo Hello world per esp8266.

Per praticità ho deciso di racchiudere tutto il codice in una classe in modo da poterlo far diventare una "Library" a se stante.

Come IDE di sviluppo utilizzerò Visual Studio Code con Platform IO. 

Repository su GitHub

Come primo step creo un nuovo repository su GitHub come di seguito:

New repository on GitHub

Sinceramente non sono sicuro che il template del file .gitignore sia corretto. Naturalmente sarà possibile modificare in futuro. 

Dopo aver creato il repository su GitHub, è necessario clonarlo su una cartella locale.

Questa operazione può essere fatta da Visual Studio Code. I passi sono i seguenti:

1) Clicca sulla tab a sinistra "Source Control"

2) Clicca sui tre puntini e poi su "Clone"

GitHub - Repository Clone

3) Verifica se tutto ok. Ho modificato il file Readme aggiungendo uno testo di prova.

Il file readme compare tra le "Pending changes".

Faccio prima un commit e poi un sync. Il file è correttamente aggiornato su GitHub.

Avendo configurato il versioning del codice adesso possiamo passare alla configurazione del progetto.  

Creazione del progetto con PlatformIO

Come anticipato, ho deciso di utilizzare Platform IO come IDE di sviluppo.

Come prima cosa apro Visual Studio Code e creo un nuovo progetto con Platform IO (che avevo già installato come estensione).

I parametri da selezionare sono:

  • Il nome del progetto -> Esp8266DynamicConnection (il nome di un progetto è sempre la parte più difficile!)
  • La board da utilizzare -> Espressif Generic ESP8266 ESp-01 1M (utilizzerò un modulo ESP01 da  1M di memoria)
  • Il framework -> Arduino
  • Il percorso della cartella in cui salvare il progetto

PlaformIO - New project

NOTA: per errore ho selezionato un percorso diverso da quello in cui avevo clonato il repository di GitHub. Appena creato il progetto, sposterò tutti i files del progetto nella cartella relativa al repository clonato. Ovviamente dovrò riaprire il progetto dalla nuova location.

Il codice ESP01

Il codice è su GitHub e l'url è il seguente:

https://github.com/giemma/Esp8266DynamicConnection

Lo riporto anche qui per completezza, ma è meglio fare riferimento a GitHub

//main.cpp
#include <Arduino.h>
#include <ESP8266WebServer.h>
#include <WiFiUDP.h>


#include "Portal.h"

Portal* portal = new Portal();

void setup() {
      Serial.begin(115200);
      portal->initialize();
}

void loop() {
  portal->handleClient();

}
//Portal.h
class Portal{
	private:
		ESP8266WebServer* server;
    	WiFiUDP UDP;
		IPAddress myIP; 
		
		String apSsid= "NewDevice2" ;
		String apPassword ="12345678";
		
    char ssid[32]={};
    char password[32]={};
    char dName[32]={};
    int localUdpPort = 65001;
    
    bool credentialsFound;
    bool connectedToLocal;
    bool triedConnectedToLocal;
    
		String GetHtmlTemplate(String content);
		
		//pages
		void showIndexPage();
		void showConfigurePage();
		void showSavePage();
		void showInfoPage();
    	void showServerInfoPage();
		void showNotFoundPage();

    //credentials
    void loadCredentials();
    void saveCredentials();

    //connections
    bool connectToLocal();
	void sendBroadcastPacket();

	public:
		Portal();
		void initialize();
		void handleClient();
		

};
//portal.cpp
#include "String.h"
#include "Arduino.h"
#include <ESP8266WiFi.h>
#include <WiFiUDP.h>
#include <ESP8266WebServer.h>
#include <EEPROM.h>
#include "Portal.h"

//costruttore
Portal::Portal(){
	server = new ESP8266WebServer(80);
   

}

bool Portal::connectToLocal()
{
  triedConnectedToLocal=true;
  //try to connect to saved wifi
  WiFi.begin(ssid, password);
  unsigned tryes=0;
  while (WiFi.status() != WL_CONNECTED )
  {
    //if(millis()>timeout+2000)
    if(tryes > 10)
    {
      //se dopo 10 secondi non si è ancora connesso allora ritorno false
      Serial.println(" connection timeout");
      break;
    }
    tryes++;
    delay(600);
    Serial.print(".");
  }
  Serial.print(" .");
  return WiFi.status() == WL_CONNECTED;
}

void Portal::initialize ()
{	
	Serial.println();
	Serial.println();
	Serial.println("Portal initializing...");
  
  Serial.println("Loading credentials...");
  loadCredentials();
 
	WiFi.softAP(apSsid, apPassword ); 
	myIP = WiFi.softAPIP();
	Serial.print("AP IP address: ");
	Serial.println(myIP);
	delay(1000);
	
	server->on("/", std::bind(&Portal::showIndexPage, this));
	server->on("/fwlink", std::bind(&Portal::showIndexPage, this));
	server->on("/configure", std::bind(&Portal::showConfigurePage, this));
	server->on("/save", std::bind(&Portal::showSavePage, this));
	server->on("/info", std::bind(&Portal::showInfoPage, this));
  server->on("/serverInfo", std::bind(&Portal::showServerInfoPage, this));
	//server.on("/", [&]() { showInfoPage();});
	server->onNotFound (std::bind(&Portal::showNotFoundPage, this));
	
	server->begin();  
	delay(2000);
	Serial.println("Portal initialized!");
  
  if(credentialsFound==true){
    Serial.println("Connecting to saved network...");
    connectedToLocal = connectToLocal();
    if(connectedToLocal==true){
      Serial.println(" connected");

      for(int i=0;i<100;i++){
        delay(100);
        sendBroadcastPacket();   
      }
      

    }else{
      Serial.println(" NOT connected. Please configure the network");
    }
  }
}

void Portal::handleClient ()
{
	server->handleClient();
}

void Portal::showIndexPage ()
{
	Serial.println("/");
	
	String body=String("<a href=\"/configure\">Configure</a> | <a href=\"/serverInfo\">serverInfo</a> | <a href=\"/info\">Info</a> ");
	String page=GetHtmlTemplate(body);
	
	server->sendHeader("Content-Length", String(page.length()));
	server->send(200, "text/html", page);
}

void Portal::showConfigurePage ()
{
	Serial.println("/configure");
	loadCredentials();

  if(triedConnectedToLocal==false)
  {
  //  Serial.println("Provo aconnettermi");
    connectedToLocal = connectToLocal();
  //  Serial.println(connectedToLocal);
  }
  
  
	int numberOfNetworks = WiFi.scanNetworks();
	String html = String();
 
    if(connectedToLocal){
      html+= "<div><label  style='color:green;'>Connected to " + (String(ssid)) +". IP:"+ WiFi.localIP().toString() +"</label> </div>";
    }else{
      html+= "<div><label style='color:red;'>Connection failed for " + (String(ssid)) +"</label></div>";
    }
    
    if(credentialsFound){
      html+= "<div><label  style='color:green;'>Credentials found</label> </div>";
    }else{
      html+= "<div><label style='color:red;'>Credentials NOT found</label> </div>";
    }
     html+="<FORM action=\"/save\" method=\"post\">";
     html+= "<div><label>Name (maxlength 30): </label> <input type='text' name='dname' value='" + (String(dName)) +"' maxlength='30' /></div>";
     html+= "<div><label>Password: </label> <input type='text' name='pwd' value='" + (String(password)) +"' maxlength='30' /></div>";
     html+="<div><label>Select a network:</label>";
     html+="<select name='ssid' >";

     String networks[numberOfNetworks];
     for(int i =0; i<numberOfNetworks; i++){
        bool networkFound=false;   
        String network = WiFi.SSID(i);

        //remove duplicates
        for(int j=0;j<=i;j++){
          if(networks[j]==network){
            networkFound=true;
          }
        }

        if(networkFound==false){
            networks[i]=network;
        }else{
            networks[i]="";
        }
     }
     
     for(int i =0; i<numberOfNetworks; i++){
        if(networks[i] == "")
        {
          continue;
        }
        html+= "<option value='";
        html+= networks[i];              
        html+= "'";
        if(networks[i]== ssid){
            html += " selected ";
          }        
        html+= " >";
        html+= networks[i];
        
        //html += " (";
        //html += WiFi.RSSI(i);
        //html += ")";
        
        html+= "</option>";
     }
     html+= "</select></div>";
     html+= "<div><input type='submit' value='Submit' ></div>";
     html+= "</form>";

  
	String page=GetHtmlTemplate(html);

  Serial.println(page);
  
	server->sendHeader("Content-Length", String(page.length()));
	server->send(200, "text/html", page);
}

void Portal::showSavePage ()
{
	Serial.println("/save");

  String html = String("");
  html+= "<div><label  style='color:green;'>Credentials found</label> </div>";
  html+= "<div><a href=\"/configure?"+ String(millis()) + "\">Return to configure</a> Credentials found</label> </div>";
	if (server->hasArg("dname") && server->hasArg("pwd") && server->hasArg("ssid")  ) { //se è stato inviato un valore
    
    server->arg("ssid").toCharArray(ssid, sizeof(ssid) - 1);
    server->arg("pwd").toCharArray(password, sizeof(password) - 1);
    server->arg("dname").toCharArray(dName, sizeof(dName) - 1);

    saveCredentials();
    triedConnectedToLocal=false;
/*
    server.arg("dName").toCharArray(dName, sizeof(dName) - 1);
    server.arg("ssid").toCharArray(ssid, sizeof(ssid) - 1);
    server.arg("pwd").toCharArray(password, sizeof(password) - 1);
*/  
    Serial.println(ssid);
    Serial.println(password);
    Serial.println(dName);
    
  }else{
    showConfigurePage();
    return;
  }
    
	String page=GetHtmlTemplate(html);
	server->sendHeader("Content-Length", String(page.length()));
	server->send(200, "text/html", page);
}

void Portal::showInfoPage ()
{
	Serial.println("/info");
	
	String info ="Info..";
	
	String html=GetHtmlTemplate(info);
	server->sendHeader("Content-Length", String(html.length()));
	server->send(200, "text/html", html);
}

void Portal::sendBroadcastPacket(){
  if(connectedToLocal==false){
    Serial.println("I can't send packets because I'm not connected :(");
    return;
  }

  Serial.println("Sending packet...");
  UDP.begin(localUdpPort);
  UDP.beginPacket("255.255.255.255", localUdpPort);
  UDP.write("Hello");
  UDP.endPacket();
  delay(10);
  Serial.print(" sent!");
}

void Portal::showServerInfoPage ()
{
  Serial.println("/ServerInfo");

  String info =String("Sending packets sent to 255.255.255.255 on ");
  info+= localUdpPort ;
  info += " port...";
  
  sendBroadcastPacket();

  Serial.print(info);

  
  
  Serial.println("sent!");
    
  String html=GetHtmlTemplate(info);
  server->sendHeader("Content-Length", String(html.length()));
  server->send(200, "text/html", html);
}

void Portal::showNotFoundPage ()
{
	String message = "File Not Found\n\n";
	message += "URI: ";
	message += server->uri();
	message += "\nMethod: ";
	message += ( server->method() == HTTP_GET ) ? "GET" : "POST";
	message += "\nArguments: ";
	message += server->args();
	message += "\n";

	for ( uint8_t i = 0; i < server->args(); i++ ) {
		message += " " + server->argName ( i ) + ": " + server->arg ( i ) + "\n";
	}
	
	server->sendHeader("Cache-Control", "no-cache, no-store, must-revalidate");
	server->sendHeader("Pragma", "no-cache");
	server->sendHeader("Expires", "-1");
	server->sendHeader("Content-Length", String(message.length()));
	server->send ( 404, "text/plain", message );
}

String Portal::GetHtmlTemplate(String content){
  String result = String( "<!DOCTYPE HTML>")+  
    "<html>"+
    "<head>"+
    "<meta name = \"viewport\" content = \"width = device-width, initial-scale = 1.0, maximum-scale = 1.0, user-scalable=0\">"+
    "<title>Device configuration</title>"+
    "<style>"+
    "\"body { background-color: #808080; font-family: Arial, Helvetica, Sans-Serif; Color: #000000; }\""+
	"a{background-color: gray;margin:5px;}"+
    "</style>" +
    "</head>" +
    "<body>" +
    
    "<h1>Device configuration</h1>" +

    content +
        
    "</body>" +
    "</html>";

  return result;
}

void Portal::loadCredentials() {
  EEPROM.begin(512);
  EEPROM.get(0, ssid);
  EEPROM.get(0+sizeof(ssid), password);
  EEPROM.get(0+sizeof(ssid) + sizeof(password), dName);
  char ok[2+1];
  EEPROM.get(0+sizeof(ssid)+sizeof(password) + sizeof(dName), ok);
  EEPROM.end();
  if (String(ok) != String("OK")) {
    credentialsFound=false;
  }else{
    credentialsFound=true;
  }
  Serial.println("Recovered credentials:");
  Serial.println(ssid);
  Serial.println(password);
  Serial.println(dName);
}


void Portal::saveCredentials() {
  EEPROM.begin(512);
  EEPROM.put(0, ssid);
  EEPROM.put(0+sizeof(ssid), password);
  EEPROM.put(0+sizeof(ssid) + sizeof(password), dName);
  char ok[2+1] = "OK";
  EEPROM.put(0+sizeof(ssid)+sizeof(password) + sizeof(dName), ok);
  EEPROM.commit();
  EEPROM.end();
  Serial.println("Credentials saved");
}

Credo che il codice sia abbastanza chiaro e inoltre tenere collegato l'ESP-01 e aprire il monitor seriale per vedere tutte le operazioni che esegue. Ho volutamente lasciato tutte le chiamate alla "Serial.printQualcosa". In ogni caso di seguito una piccola spiegazione.

La libreria ha un metodo "Initialize" che rappresenta l'inizio del workflow. 

Come prima operazione viene chiamata la funzione "loadCredentials" che ha il compito di leggere eventuali credenziali precedentemente salvate.

Successivamente l'ESP8266 viene impostato come access point, viene stampato il suo ip sulla seriale e configurate le varie funzioni che dovranno essere chiamate quando si proverà ad accedere ad un percorso.

A questo punto, se vengono trovate le credenziali, l'ESP8266 tenta una connessione alla rete salvata. Se connesso invia 100 pacchetti UDP in broadcast. Il resto dipende da cosa si vuol fare.

NOTA: Fondamentale è la chiamata della funzione handleClient che deve necessariamente stare nel "loop()" del main.

Allo stato attuale la home page contiene solo un titolo e i link per impostare la connessione:

ESP8266 - ConfigurationHome


La pagina più importante è la pagina di configurazione:

DeviceCongiguration - Connected to network

In questa pagina è possibile vedere lo stato della connessione, l'ip dell'ESP8266 e le informazioni salvate.

Nel caso in cui il modulo non riesce a connettersi la schermata sarà qualcosa di simile:

DeviceConfiguration - Not Connected

Alla pressione del tasto "Submit" verrà visualizzata la seguente pagina:

DeviceConfiguration - Credentials saved

Il link "Return to configure", punta alla pagina di configurazione, ma appende in query string il numero di millisecondi. E' un trucchetto per non avere problemi di caching.

La pagina "ServerInfo"attualmente invia un pacchetto UDP in broadcast. Non fa altro.

Criticità

I punti che più hanno causato problemi sono sostanzialmente due.

Il primo è che se l'ESP8266 impiega molto tempo a connettersi e quindi a restituire la pagina web, alcuni browser richiamano la pagina più volte. Ho risolto con un semplice IF.

Il secondo punto rappresenta invece una grossa limitazione. L'invio dei pacchetti in broadcast potrebbe esse bloccato a causa dell'impostazione di "host isolation" o altro. In questi casi, basterebbe sostituire l'ip 255.255.255.255 con l'ip del server. Naturalmente è fattibile per noi smanettoni, ma trovo improbabile che mia nonna possa conoscere l'ip di qualcosa.

Questo secondo punto mi ha fatto desistere dal continuare sulla strada presentata all'inizio di questo articolo.

Opterò per un altro processo di "registrazione" alla rete, ma non voglio anticipare nulla.

Ne riparliamo nel prossimo articolo