NodeMCU: Prosty serwer WebSocket

W dzisiejszym artykule dowiemy się jak na procesorze umieszczonym w NodeMCU zaimplementować funkcjonalność serwera WebSocket. Pozwoli nam to za pomocą jednego gniazda TCP zapewnić w prosty sposób dwukierunkową komunikację klient-serwer. Zaczynajmy więc!

Czym jest WebSocket możemy dowiedzieć się z polskiej lub angielskiej Wikipedii (w wersji angielskiej jest nieco bardziej obszerny opis). Aby móc w prosty sposób postawić taki serwer na NodeMCU skorzystami z biblioteki  o nazwie WebSockets autostwa Markusa Sattlera.

Jak widać jest ona dostępna w repozytorium bibliotek dla Arduino więc jej instalacja nie powinna sprawić żadnego problemu.

Cel szkicu

Celem szkicu napisanego jako przykład w tym artykule będzie raportowanie stanu pinów cyfrowych naszego ESP. Przykładowo jeżeli użytkownik wyda komendę „status 5” to w odpowiedzi dostanie informację „Stan pinu 5 to 1” lub „Stan pinu 5 to 0”. Dodatkowo nasz serwer będzie bardzo kulturalnym serwerem i za każdym razem, kiedy ktoś się z nim połączy to się przywita :)

Piszemy program

Pierwsze co należy zrobić to zaimportować odpowiednie biblioteki:

#include <ESP8266WiFi.h>
#include <WebSocketsServer.h>

Pierwszą z nich doskonale znamy – jest to biblioteka obsługująca moduł WiFi w naszym procesorze. Druga natomaist pomoże nam uruchomić serwer WebSocket.

Teraz ustawiamy kilka globalnych stałych i zmiennych:

const char* ssid     = "nazwa sieci";
const char* password = "haslo";
const uint8_t availableDigitalPins[] = {1,3,5,4,0,2,15,10,9,13,12,14,16};
WebSocketsServer webSocket = WebSocketsServer(81);

Stała tablica availableDigitalPins przechowuje numery pinów, które może odczytać użytkownik. Odpowiada ona temu pinoutowi:

Nasz serwer WebSocket uruchamiamy na porcie 81, stąd liczba przekazana jako parametr konstruktora.

Funkcję setup() podzieliłem na 3 bloki rozdzielone pustą linią. Są to kolejno:

  1. Inicjalizacja portu szeregowego
  2. Łączenie z siecią WiFi
  3. Uruchomienie oraz konfiguracja serwera
void setup() {
  Serial.begin(115200);

  Serial.println();
  Serial.println();
  Serial.printf("Laczenie z %s", ssid);
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();
  Serial.println("Polaczono");
  Serial.print("Adres IP: ");
  Serial.println(WiFi.localIP());
  
  webSocket.begin();
  webSocket.onEvent(webSocketEvent);
}

W trzecim bloku uruchamiamy serwer (za pomocą metody begin()) oraz ustalamy tzw. event listener (czyli funkcję, która wywoła się po zarejestrowaniu przez serwer konkretnego zdarzenia). Tutaj naszym eventem jest jakakolwiek komunikacja klienta w stronę serwera.

Nasz listener powinien mieć taki nagłówek:

void webSocketEvent(uint8_t num, WStype_t type, uint8_t * payload, size_t length);

Pierwsza zmienna to numer podłączonego klienta, druga to typ eventu (np. połączenie, rozłączenie, komunikat tekstowy), w zmiennej payload znajdziemy zawartość komunikatu, a length to jego długość.

My nasz event listener zaimplementujemy następująco:

void webSocketEvent(uint8_t num, WStype_t type, uint8_t * payload, size_t length) {
  char * charPayload = (char *) payload;
  switch(type) {
    case WStype_DISCONNECTED:
      webSocketDisconnected(num);
      break;
    case WStype_CONNECTED:
      webSocketConnected(num, charPayload);
      break;
    case WStype_TEXT:
      webSocketGotText(num, charPayload);
      break;
  }
}

Jak widać w zależności od typu eventu przekazujemy sterowanie do kolejnych funkcji. Poprawi to czytelność kodu w przypadku kiedy będizemy chcieli bardziej rozbudować projekt.

Funkcje odpowiedzialne za obsługę rozłączenia i połączenia użytkownika są bardzo proste:

void webSocketDisconnected(uint8_t num) {
  Serial.printf("[%u] Klient rozlaczyl sie\n", num);
}

void webSocketConnected(uint8_t num, char * url) {
  IPAddress ip = webSocket.remoteIP(num);
  Serial.printf("[%u] Nowe polaczenie z adresu %d.%d.%d.%d, URL: %s\n", num, ip[0], ip[1], ip[2], ip[3], url);
  webSocket.sendTXT(num, "Czesc :)");
}

Jedyne co robią to wyświetlają informację na porcie szeregowym. Funkcja webSocketConnected wysyła też wiadomość powitalną do nowego użytkownika.

Zanim przedstawię jak działa webSocketGotText musimy zaimplementować sobie jeszcze 2 funkcje pomocnicze. Pierwsza z nich to getParam odpowiedzialna za pobieranie odpowiedniego parametru z komendy.

Zakładamy, że nasza komenda składa się z parametrów rozdzielonych spacją. Komenda „status 5” rozbita na paeametry to 0: status oraz 1: 5. Aby wyciągnąć konkretny parametr z komendy napisałem taką oto funkcję:

void getParam(char * command, uint8_t paramIndex, char * result) {
  strcpy(result, "");
  uint8_t spacesCount = 0;
  uint8_t paramStartPos = 0;
  for(int x = 0; x < strlen(command); x++) {
    if(spacesCount == paramIndex) {
      if(command[x] == ' ') {
        result[x - paramStartPos] = '\0';
        return;
      } else {
        result[x - paramStartPos] = command[x];
      }
    }
    if(command[x] == ' ') {
      spacesCount++;
      paramStartPos = x+1;
    }
  }
}

Druga funkcja pomocnicza służy do sprawdzania, czy dana wartość występuje w tablicy. Przyda nam się ona do sprawdzania, czy dany pin jest pinem, który może odczytać użytkownik:

bool inArray(uint8_t needle, const uint8_t * haystack, uint8_t size) {
  for(uint8_t x = 0; x < size; x++) {
    if(needle == haystack[x]) return true;
  }
  return false;
}

Ok, teraz z czystym sumieniem możemy przejść do implementacji webSocketGotText:

void webSocketGotText(uint8_t num, char * payload) {
  Serial.printf("[%u] Otrzymano tekst: %s\n", num, payload);
  
  char result[20] = {'\0'};
  char response[50] = {'\0'};
  
  getParam(payload, 0, result);
  if(strcmp(result, "status") == 0) {
    getParam(payload, 1, result);
    int pinNumber = atoi(result);
    if(inArray(pinNumber, availableDigitalPins, sizeof(availableDigitalPins) / sizeof(uint8_t))) {
      sprintf(response, "Stan pinu %d to %d", pinNumber, digitalRead(pinNumber));
    } else {
      sprintf(response, "Pin %d nie istnieje", pinNumber);
    }
    
    webSocket.sendTXT(num, response);
  }
}

Na początku deklarujemy sobie dwa bufory. W buforze result będziemy trzymali aktualnie przetwarzany parametr, natomiast response to nasza odpowiedź do klienta.

Za pomocą funkcji strcmp() sprawdzamy, czy peirwszy parametr to „status”. Jeżeli tak, to parsujemy numer pinu do zmiennej liczbowej, sprawdzamy za pomocą funkcji inArray, czy użytkownik może go odczytać i wysyłamy odpowiedź do klienta :)

W podobny sposób możemy zaimplementować sobie inne komendy np. set x y, która ustawi stan y na pinie x.

Komunikacja

Z tak zaimplementowanym serwerem możemy komunikować się z dowolnego innego programu. Najczęściej WebSockety używane są w JavaScripcie do komunikacji przeglądarki z serwerem. Do testowania można pobrać sobie dowolnego klienta WebSocket. Ja używam dodatku do Chrome o nazwie Simple WebSocket Client.

Moja komuniakcja z ESP wygląda tak:

Zadanie domowe

Zachęcam do zaimplementowania innych komend, takich jak np. set. Można też dodać czujniki do naszego ESP np. czujnik temperatury i zaimplementować komendę getTemperature. Możliwoci jest sporo.