NodeMCU – Odbieranie danych przez protokół UDP

Po lekturze ostatnich dwóch (1 i 2) artykułów z serii o NodeMCU powinniśmy umieć już postawić własną sieć WiFi oraz zapisać oraz odczytać konfigurację „na sztywno”. Dziś dopiszemy klasę, która pozwoli nam na wprowadzenie danych przez użytkownika bez ingerencji w kod programu.

Sposobów na komunikację z użytkownikiem jest wiele. Można np. postawić serwer, który będzie hostował stornę WWW na której użytkownik może wprowadzić dane. Innym podejściem jest przesłanie danych z komputera przez USB lub przez Bluetooth. Dziś jednak zajmiemy się komunikacją przez protokół UDP.

Dlaczego UDP?

Protokół ten jest pozwala na szybką wymianę danych przez sieć co w przypadku ESP8266 oznacza brak dodatkowych kabelków itp. Dodatkowo obsługa tego protokołu jest banalnie prosta przy wykorzystaniu bibliotek do Arduiono dla NodeMCU. Co więcej wykorzystanie UDP będzie dużo bardziej oryginalne niż oklepany port szeregowy, a przecież chodzi o to, żeby nauczyć się czegoś nowego :)

Struktura klasy

Tak jak w poprzednim artykule tutaj też praktycznie cały kod do obsługi nowej funkcjonalności wrzucimy do osobnej klasy. Nazwijmy ją UDPMessengerService i zróbmy dla niej plik nagłówkowy o nazwie UDPMessengerService.h.

#ifndef UDPMESSENGERSERVICE_H
#define UDPMESSENGERSERVICE_H

#include <WiFiUdp.h>
#include <ESP8266WiFi.h>
#include "ConfigManager.h"
#include "ArduinoJson.h"

class UDPMessengerService {
  private:
    static const int MAX_PACKET_SIZE = 512;
    ConfigManager config = ConfigManager::getInstance();
    WiFiUDP udp;

    void processMessage(IPAddress senderIp, uint16_t senderPort, char *message);
    void sendPacket(IPAddress ip, uint16_t port, const char* content);
    void getDeviceInfo(JsonObject &result);
    void sendNetworksList(IPAddress ip, uint16_t port);
    
  public:
    UDPMessengerService(uint16_t port);
    void listen();
};

#endif /* UDPMESSENGERSERVICE_H */

Jak widać z publicznych metod mamy tutaj tylko konstruktor oraz metodę listen(), która posłuży nam do ciągłego nasłuchiwania nadchodzących pakietów oraz odpowiadania na nie. Jeśli chodzi o część prywatną to mamy tutaj metody odpowiedzialne za przetwarzanie wiadomości, wysyłanie pakietów, pobieranie informacji o urządzeniu i wysyłanie listy dostępnych sieci. Niemniej ważnym elementem jest prywatne pole o nazwie udp typu WiFiUDP. To dzięki temu obiektowi będziemy mogli komunikować się przez User Datagram Protocol.

Format danych

Dane będziemy przesyłali w formacie JSON (podobnie jak w klasię z configiem). Ustalmy więc jakąś spójną strukturę.

Zapytanie

Przesyłając zapytanie do naszego urządzenia musimy przekazać mu przynajmniej jedną informację – komendę, którą ma wykonać. Może też się zdarzyć tak, że komenda będzie zawierała w sobie jakieś dodatkowe parametry. Dlatego proponują taki format danych:

{
  "cmd" : "komenda",
  "params" : { opcjonalne parametry }
}

Jakie komendy będziemy implementowali?

  • getDeviceInfo – pobieranie informacji o urządzeniu
  • fetchNetworks – pobieranie informacji o dostępnych sieciach WiFi
  • doReboot – restart urządzenia
  • setWiFi – ustawianie parametrów WiFi
    • Parametry:
      • ssid – nazwa sieci
      • password (opcjonalne) – hasło

Przykładowa komenda:

{
  "cmd":"setWiFi",
  "params":{
    "ssid":"Moja siec",
    "password":"12345678"
  }
}

Odpowiedź

Podobnie jak przy zapytaniu w odpowiedzi powinna znaleźć się informacja na co odpowiadamy i wynik operacji. Dlatego proponuję taką strukturę:

{
  "type":"typ odpowiedzi",
  "result":{wynik}
}

Przykładowa odpowiedź:

{
  "type":"setWiFiFeedback",
  "result":"WiFi credentials saved successfully"
}

Implementacja klasy

Znamy już strukturę kalsy oraz format przesyłanych danych. Teraz możemy przejść do implementacji. Zacznijmy od konstruktora:

UDPMessengerService::UDPMessengerService(uint16_t port) {
  udp.begin(port);
}

Jak widać pierwsze co musimy zrobić to otworzyć odpowieni port UDP. Jeśli chodzi o konstruktor to by było na tyle :)

Przyjrzyjmy się metodzie listen():

void UDPMessengerService::listen() {
  int packetSize = udp.parsePacket();
  if(packetSize) {
    char incomingPacket[MAX_PACKET_SIZE];
    int len = udp.read(incomingPacket, MAX_PACKET_SIZE);
    if (len > 0) {
      incomingPacket[len] = 0;
    }
    processMessage(udp.remoteIP(), udp.remotePort(), incomingPacket);
  }
}

Tutaj wykorzystujemy metodę parsePacket() klasy WiFiUDP w celu sparsowania przychodzących pakietów. Jeżeli taki pakiet nie przyjdzie to wynik funkcji będzie równy 0 i tym samym instrukcja warunkowa w następnej linijce nie pozwoli na dalsze odczytywanie danych (bo i tak ich nie ma).

Jeżeli pakiet jednak przyjdzie to należy odczytać go do tablicy znaków (kończąc znakiem NULL) i przekazać taki ciąg do dalszego przetwarzania już w metodzie processMessage().

Skoro wykonywanie programu przechodzi do processMessage to i my tam przejdźmy i zaimplementujmy tę metodę.

Zacznijmy od sparsowania danych przesłanych w JSONie

StaticJsonBuffer<200> jsonBuffer;
JsonObject &root = jsonBuffer.parseObject(message);
if(!root.success()) {
  return;
}

Zakładamy, że jeżeli nie uda się sparsować pakietu to jest on niepoprawny i wychodzimy z funkcji, czyli nie reagujemy na przesłane dane.

Ok teraz wyciągnijmy kilka przydatnych informacji ze sparsowanego JSONa:

const char *cmd = root["cmd"];
JsonObject &params = root["params"];
JsonObject &reply = jsonBuffer.createObject();
char replyMessage[MAX_PACKET_SIZE] = "";

Teraz należy sprawdzić komendę, którą otrzymaliśmy i odpowiedzieć na niej. Lecimy więc po kolei:

Pobieranie informacji o urządzeniu:

if(!strcmp(cmd, "getDeviceInfo")) {
  reply["type"] = "deviceInfo";

  StaticJsonBuffer<200> jsonBuffer;
  JsonObject &result = jsonBuffer.createObject();
  getDeviceInfo(result);
  reply["result"] = result;
}

Metodę getDeviceInfo() zaimplementujemy po omówieniu processMessage().

Pobieranie dostępnych sieci:

else if(!strcmp(cmd, "fetchNetworks")) {
  sendNetworksList(senderIp, senderPort);
  return;
}

Podobnie jak getDeviceInfo() metodę sendNetworksList() zaimplementujemy później.

Restart urządzenia:

else if(!strcmp(cmd, "doReboot")) {
  WiFi.mode(WIFI_STA);
  WiFi.disconnect();
  ESP.reset();
  return;
}

Ustawienie parametrów WiFi:

else if(!strcmp(cmd, "setWiFi")) {
  if(!params.success()) {
    return;
  }

  if(!params.containsKey("ssid")) {
    return;
  }

  const char* ssid = params.get<const char*>("ssid");
  const char* password = "";

  if(params.containsKey("password")) {
    password = params.get<const char*>("password");
  }

  config.setSSID(ssid);
  config.setPassword(password);

  reply["type"] = "setWiFiFeedback";
  if(config.save()) {
    reply["result"] = "WiFi credentials saved successfully";
  } else {
    reply["result"] = "Error during saving WiFi credentials";
  }
}

Tutaj mamy trochę więcej roboty, więc przeanalizujmy ciało metody krok po kroku. Najpierw sprawdzamy, czy zostały przekazane dodatkowe parametry. Następnie sprawdzamy, czy parametry te zawierają klucz o nazwie „ssid”. Jeżeli tak to pobieramy je i przyjmujemy, że hasło jest puste. Teraz sprawdzamy, czy jest jakieś hasło i jeżeli tak to podmieniamy nasze puste hasło na to, które przyszło z danymi. Teraz wystarczy skorzystać z naszego obiektu klasy ConfigManager do zapisania danych i ustalenia wyniku.

Ostatnia z możliwości, czyli niepoprawna komenda:

else {
  reply["type"] = "unknown";
}

Teraz pozostało nam już tylko wrzucić odpowiedź do tablicy znaków i odesłać pakiet nadawcy zapytania:

reply.printTo(replyMessage, MAX_PACKET_SIZE);
sendPacket(senderIp, senderPort, replyMessage);

Jak wysyłamy pakiety? Wystarczą do tego praktycznie 3 linijki:

void UDPMessengerService::sendPacket(IPAddress ip, uint16_t port, const char* content) {
  udp.beginPacket(udp.remoteIP(), udp.remotePort());
  udp.write(content);
  udp.endPacket();
}

Najpierw należy zainicjować pakiet, następnie wpisać do niego zawartość i zakończyć. Nic prostszego :)

Przejdźmy teraz do obiecanych wcześniej metod, czyli getDeviceInfo() oraz sendNetworksList().

void UDPMessengerService::getDeviceInfo(JsonObject &result) {
  result["serialNumber"] = ESP.getChipId();
  // Możemy tutaj dopisać inne parametry, np. nazwę sieci do której ESP się łączy
}

W tym przypadku sprawa jest prosta, ale wystarczy dorzucić kilka dodatkowych parametrów i funkcja może rozrosnąć się na tyle, że dobrze jest mieć ją gdzieś osobno.

Teraz czas na wysłanie listy sieci:

void UDPMessengerService::sendNetworksList(IPAddress ip, uint16_t port) {
  int networksCount = WiFi.scanNetworks();
  char resultBuffer[MAX_PACKET_SIZE] = "";     
  
  for(int i = 0; i < networksCount; i++) {
    int32_t signalQuality = WiFi.RSSI(i);

    if(signalQuality <= -100) {
      signalQuality = 0;
    }
    else if(signalQuality >= -50) {
      signalQuality = 100;
    }
    else {
      signalQuality = 2 * (signalQuality + 100);
    }

    StaticJsonBuffer<200> jsonBuffer;

    JsonObject &result = jsonBuffer.createObject();
    JsonObject &network = jsonBuffer.createObject();
    network["SSID"] = WiFi.SSID(i);
    network["signalQuality"] = signalQuality;
    network["isProtected"] = (bool) (WiFi.encryptionType(i) != ENC_TYPE_NONE);
    
    result["type"] = "networkData";
    result["result"] = network;
    
    result.printTo(resultBuffer, MAX_PACKET_SIZE);
    sendPacket(ip, port, resultBuffer);
  }
}

Aby pojedynczy pakiet nie był zbyt „tłusty” każdą sieć wyślemy w osobnym pakiecie. Najpierw skanujemy wszystkie dostępne sieci, a następnie w pętli for wysyłamy je, każdą w osobnym pakiecie do adresu IP, który wysłał zapytanie.

Dodajemy nasłuch w głównym programie

Skoro klasę pomocniczą mamy już gotową to możemy teraz dodać jej obsługę do naszego głównego programu.

Na początku pliku .ino dopisujemy linijkę

#include "UDPMessengerService.h"

Następnie tworzymy globalną zmienną naszego messengera. Uruchomimy go dla testu na porcie 1234 :)

UDPMessengerService udpMessengerServie(1234);

Teraz wystarczy, że w pętli while() w miejscu, gdzie w poprzednim artykule był komentarz o treści

/**
 * Tutaj w przyszłości uruchomimy nasłuch UDP lub HTTP.
 * Zadba on o pobranie i zapisanie ustawień, a także restart urządzenia.
 */

wstawiamy

udpMessengerServie.listen();

Możemy to zrobić też w funkcji loop(). Dzięki temu będziemy mogli komunikować się za pomocą UDP nawet jeżeli ESP już podłączy się do właściwej sieci.

Czas na testy

Do komunikacji przez UDP możemy wykorzystać program o nazwie Packet Sender.

Pierwszy pakiet pobierający informacje o urządzeniu wyślemy na adres broadcastowy. Dzięki temu odezwą się wszystkie urządzenia w sieci i dodatkowo poznamy ich adresy IP :)

Adres broadcastowy dla naszej sieci możemy obliczyć za pomocą kalkulatora, który znajduje się w zakładce Tools > Subnet calculator.

Teraz możemy np. skierować prośbę o przeskanowanie sieci WiFi do konkretnego urządzenia:

A następnie wysłać mu nową konfigurację:

To by było na tyle

Na dziś to już koniec. W następnym artykule zaimplementujemy analogiczna funkcjonalność z tą różnicą, że tym razem uruchomimy serwer HTTP, który udostępni konfiguracyjną stronę WWW.