Prosty parser stringów na Arduino / Teensy

Dzisiejszy artykuł będzie trochę z innej beczki, bo zamiast pokazywać jak napisać kod  do danego sprzętu zbudujemy sobie sprzęt, który będzie pomagał w zrozumieniu kodu :) Czasem istnieje konieczność skomunikowania naszego urządzenia np. z komputerem przez konwerter USB-UART, telefonem przez bluetooth lub między 2 naszymi urządzeniami przez UART. Początkującym może to sprawić pewne problemy, dlatego dziś zajmiemy się zagadnieniem jakim jest interpretowanie komend przychodzących do naszego urządzenia przez port szeregowy oraz odpowiednie reagowanie na nie i wysyłanie informacji zwrotnych.

Założenia

Popularnie stosowanym standardem są komendy AT. Standard ten został opracowany z myślą o modemach itp. jednak nic nie stoi na przeszkodzie, abyśmy my zaimplementowali sobie uproszczoną wersję tego standardu w naszym urządzeniu. W celu prezentacji działania naszej implementacji zbudujmy sobie prosty układ świateł drogowych sterowany przez bluetooth z telefonu. Nasze urządzenie będzie mogło być wysterowane czterema komendami:

  • AT+STATE=stan – ustawienie stanu od 0 do 4, gdzie stany 0 – 3 to cały cykl świateł drogowych zaczynając od światła czerwonego, a 4 to światła wyłączone (migające pomarańczowe).
  • AT+AUTO=automat – decydujemy, czy światła mają działać automatycznie (1), czy będziemy zmieniali kontrolowali ich przełączanie ręcznie (0).
  • AT+LEDS=czerwone, żółte, zielone – podajemy stan dla każdej diody (możemy np. ustawić, że wszystkie diody są włączone).
  • AT+AUTOTOGGLE – zmiana ustawienia AT+AUTO na przeciwne.

Pierwsze 3 z powyższych komend można zapisać ze znakiem zapytania (np. AT+STATE?). Wtedy urządzenie zwróci nam aktualne ustawienie. Dodatkowo jest jeszcze komenda „AT” po wpisaniu której urządzenie zwraca ciąg „OK”.

Potrzebne części

Przydatna będzie też aplikacja na telefon, która pozwoli nam na komunikację przez Serial Port Profile. Jeśli macie telefon z Androidem to polecam Bluetooth Terminal HC-05.

Podłączenie

Aby wszystko działało jak należy wystarczy podłączyć LEDy przez odpowiednie rezystory do pinów cyfrowych (u mnie te piny to kolejno 14, 15, 16 dla diody zielonej, żółtej i czerwonej) oraz moduł bluetooth do zasilania i portu UART (pamiętając przy tym, żeby połączyć RX do TX i wzajemnie). Po złożeniu układ powinien wyglądać mniej więcej tak (ja wykorzystałem Teensy 3.2):

bluetooth

img_5175

Teraz czas na najważniejsze, czyli

Kod

Cały kod do pobrania tutaj.

Omówmy więc po kolei poszczególne fragmenty.

Na początku mamy 2 linijki:

#define SERIAL_DATA Serial1
#define SERIAL_DEBUG Serial

W przypadku Teensy możemy zostawić tak jak jest. Jeżeli natomiast mamy Arduino z jednym portem szeregowym to Serial1 zmieniamy na Serial (jeden port posłuży nam do komunikacji i ewentualnego debugowania).

Przejdźmy do dalszej analizy.

void setState(byte state) {
  if(state <= 3 && state == currentState) return;
  switch(state) {
    case 0: // czerwone
      setLeds(true, false, false);
      break;
    case 1: // czerwone -> zielone (przygotowanie)
      setLeds(true, true, false);
      break;
    case 2: // zielone
      setLeds(false, false, true);
      break;
    case 3: // zielone -> czerwone (przygotowanie)
      setLeds(false, true, false);
      break;
    default: // jazda wg. znaków (migające pomarańczowe)  
      setLeds(false, (millis() % 1600 < 800), false);
      currentState = 4;
      return;
  }
  currentState = state;
}

W funkcji setState jedyną rzeczą, którą należałoby omówić jest wywołanie funkcji setLeds z parametrami false, (millis() % 1600 < 800), false. Środkowy parametr zwróci tutaj wartość logiczną. Co tak naprawdę się tu dzieje? Dzielimy modulo liczbę milisekund przez 1600, dzięki czemu zawsze otrzymamy tam liczbę z przedziału 0 – 1599 zwiększającą się co 1 ms. Następnie sprawdzamy, czy liczba ta jest mniejsza od 800. Teraz przez połowę okresu równego 1600 milisekund będzie stan wysoki, a przez drugą połowę niski. Należy przy tym pamiętać, aby setState było wykonywane w głównej pętli programu bez żadnych przerw np. przez delay();

Przeanalizujmy teraz 2 ostatnie, najważniejsze funkcje. Najpierw zobaczmy co dzieje się w funkcji getParams.

byte getParams(String params, byte expectedParamsCount, int * paramsOutput) {
  params.trim();
  if(params.equals("?")) return 1;
  else if(params[0] == '=') {
    for(int x = 0; x < expectedParamsCount; x++) {
      paramsOutput[x] = 0;
    }
    params = params.substring(1);
    byte temp, param = 0;
    for(byte x = 0; x < params.length(); x++) {
      if(params[x] == ' ') {
        params = params.substring(x);
        param++;
        x = 0;
        continue;
      }
      temp = params[x] - 48;
      if(temp >= 0 && temp <= 9) {
        if(param > expectedParamsCount) {
          return 2;
        }
        if(param == 0) param = 1;
        paramsOutput[param-1] = paramsOutput[param-1] * 10 + temp;
      } else {
        return 3;
      }
    }
    
    if(param == expectedParamsCount) {
      return 0;
    } else if(param < expectedParamsCount) {
      return 4;
    }
  }
  return 5;
}

Przyjmuje ona następujące wartości na wejście:

  • obiekt typu String – parametry odczytane z komendy AT,
  • liczba rozmiaru 1 bajta – ilość parametrów do zapisania w tablicy wyjściowej,
  • wskaźnik na int – tablica wyjściowa z odczytanymi parametrami.

Na początku sprawdzamy, czy nasza komenda jest zapytaniem o aktualny stan danego ustawienia (znak zapytania), czy może ma za zadanie ustawić jakieś wartości (znak równości).

  if(params.equals("?")) return 1;
  else if(params[0] == '=') {

Przejdźmy do dalszej części odczytywania parametrów. Musimy zadbać o to, by nasza tablica była wyzerowana zanim zaczniemy dopisywać do niej dane. W tym celu stosujemy fora, który przejdzie po każdym istotnym dla nas elemencie i ustawi jego wartość na 0.

for(int x = 0; x < expectedParamsCount; x++) {
  paramsOutput[x] = 0;
}

Zaraz pod tą pętlą poprzez wywołanie substring(1) pozbywamy się znaku = z naszego ciągu (tworząc podciąg zaczynający się od znaku na pozycji 1) i odczytujemy dalej. Następnie za pomocą pętli for iterujemy po każdym elemencie ciągu wejściowego.

Jeżeli natrafimy na spację oznacza to, że po niej będzie nowa zmienna. Aby obsłużyć takie zdarzenie inkrementujemy zmienną param, ucinamy ze stringa to, co już odczytaliśmy, ustawiamy x z powrotem na 0 i przechodzimy do kolejnej iteracji.

      if(params[x] == ' ') {
        params = params.substring(x);
        param++;
        x = 0;
        continue;
      }

Jeżeli nie trafiliśmy na spację to odczytujemy znak, sprawdzamy czy jest on cyfrą (odejmujemy 48, ponieważ znak '0′ w tablicy ASCII ma taką właśnie wartość dziesiętną).

Linijka if(param == 0) param = 1; przydaje się później do sprawdzenia, czy otrzymaliśmy po znaku = jakikolwiek parametr.

Teraz mnożymy wartość w tablicy wyjściowej pod indeksem odpowiadającym numerowi odczytywanego parametru przez 10 i dodajemy liczbę jedności odczytaną z wejścia.

      temp = params[x] - 48;
      if(temp >= 0 && temp <= 9) {
        if(param > expectedParamsCount) {
          return 2;
        }
        if(param == 0) param = 1;
        paramsOutput[param-1] = paramsOutput[param-1] * 10 + temp;
      }

Kiedy już odczytamy cały ciąg wejściowy sprawdzamy, czy dostaliśmy dokładnie tyle parametrów ile chcieliśmy dostać i jeśli tak to zwracamy 0. Jeśli wystąpił jakiś błąd zwracamy inne liczby.

  • 0 – wszystkie założone parametry odczytane
  • 1 – komenda była zapytaniem, brak parametrów
  • 2 – za dużo parametrów
  • 3 – jeden z parametrów nie jest liczbą
  • 4 – za mało parametrów
  • 5 – inny błąd w składni komendy lub błędna komenda

Spójrzmy teraz na realizację funkcji readSerial. Na samym początku za pomocą dwóch pętli while odczytujemy dane z bufora portu szeregowego. Używamy tutaj timeout’a równego 100 ms, aby skleić całą komendę w jeden string (bez tego zdarza się, że komenda rozbije się gdzieś na kilka mniejszych). Pomijamy też znaki nowej linii i powrotu karetki, gdyż są one dla nas całkowicie zbędne. Następnie sprawdzamy, czy nasz ciąg nie jest pusty (jeżeli jest to wychodzimy z funkcji) oraz zamieniamy wszystkie litery na wielkie.

  char temp;
  String data = "";
  unsigned long timeout = millis();
  while(millis() - timeout <= 100) {
    while(SERIAL_DATA.available()) {
      temp = SERIAL_DATA.read();
      timeout = millis();
      if(temp == '\r' || temp == '\n') continue;
      data += temp;
    }
  }

  if(data.length() == 0) return;

  data.toUpperCase();

Teraz czas na interpretowanie tego, co wchodzi na nasze wejście. Na samym początku sprawdzamy, czy komenda to „AT”. Jeżeli tak to wypisujemy „OK” i kończymy. Jeżeli nie to sprawdzamy, czy zaczyna się od „AT+”. Jeżeli tak to ucinamy 3 pierwsze litery i analizujemy dalej. Najpierw sprawdzamy wszystkie komendy, które nie przyjmują parametrów (np. jeżeli sprawdzenie czy data.equals(„AUTOTOGGLE”) byłoby po sprawdzeniu czy data.startsWith(„AUTO”)  to mielibyśmy problem ;) ). Ich realizacja jest prosta, bo mają tylko jedno działanie – zmienić coś, w dodatku zawsze tak samo. Później sprawdzamy komendy, które przyjmują jakieś parametry. W tym przypadku zapisujemy do zmiennej result wynik działania funkcji getParams, a następnie wykonujemy odpowiednie akcje z pomocą instrukcji switch.

  if(data.equals("AT")) {
    SERIAL_DATA.println("OK");
  } else if(data.startsWith("AT+")) {
    data = data.substring(3);
    int paramsOutput[3];
    
    if(data.equals("AUTOTOGGLE")) {
      autoSwitch = !autoSwitch;
      SERIAL_DATA.print("AT+AUTO=");
      SERIAL_DATA.println(autoSwitch);
    } else if(data.startsWith("STATE")) {
      byte expectedParamsCount = 1;
      byte result = getParams(data.substring(5), expectedParamsCount, paramsOutput);
      switch(result) {
        case 0:
          autoSwitch = false;
          setState(paramsOutput[0]);
        case 1:
          SERIAL_DATA.print("AT+STATE=");
          SERIAL_DATA.println(currentState);
          break;
        default:
          SERIAL_DATA.print("ERROR: ");
          SERIAL_DATA.println(result);
      }
    } else if(data.startsWith("AUTO")) {
      byte expectedParamsCount = 1;
      byte result = getParams(data.substring(4), expectedParamsCount, paramsOutput);
      switch(result) {
        case 0:
          autoSwitch = paramsOutput[0];
        case 1:
          SERIAL_DATA.print("AT+AUTO=");
          SERIAL_DATA.println(autoSwitch);
          break;
        default:
          SERIAL_DATA.print("ERROR: ");
          SERIAL_DATA.println(result);
      }
    } else if(data.startsWith("LEDS")) {
      byte expectedParamsCount = 3;
      byte result = getParams(data.substring(4), expectedParamsCount, paramsOutput);
      switch(result) {
        case 0:
          setLeds(paramsOutput[0],paramsOutput[1],paramsOutput[2]);
        case 1:
          autoSwitch = false;
          SERIAL_DATA.print("AT+LEDS=");
          SERIAL_DATA.print(digitalRead(LED_R));
          SERIAL_DATA.print(" ");
          SERIAL_DATA.print(digitalRead(LED_Y));
          SERIAL_DATA.print(" ");
          SERIAL_DATA.println(digitalRead(LED_G));
          break;
        default:
          SERIAL_DATA.print("ERROR: ");
          SERIAL_DATA.println(result);
      }
    }
  }

W przypadku, kiedy result = 0 możemy w switchu pominąć break. Dzięki temu po zapisaniu odpowiednich wartości nasze urządzenie zwróci wiadomość potwierdzającą wprowadzenie nowych danych.

Pominąłem w programie kwestie sprawdzania, czy otrzymane dane mieszczą się w zakresie, bo w przypadku tak prostego programu nie jest to konieczne. Jeżeli jednak chcielibyśmy wysterować np. diodę RGB przez PWM musimy pamiętać żeby zadbać o to, aby wypełnienie nie było większe od 255 i mniejsze od 0.

Więcej informacji o metodach dostępnych w klasie Serial oraz String można poczytać w dokumentacji na stronie Arduino.

Na koniec jeszcze fotka układu razem z krótką historią komunikacji przez bluetooth oraz filmik prezentujący działanie.

img_5167

Mam nadzieję, że tym artykułem nieco przybliżyłem początkującym jeden ze sposobów na komunikację między urządzeniami za pomocą UART :)