W dokumentacji do Arduino można znaleźć dokładny opis sposobu przesyłania danych z wykorzystaniem portów szeregowych. Problem w tym, że są to informacje, połowiczne. Prawie każdy kurs programowania Arduino, który znalazłem w sieci kończy się na pokazaniu, w jaki sposób przesłać dane z wykorzystaniem wbudowanego terminala w ArduinoIDE. Jest to oczywiście lekkie, łatwe i przyjemne. Co ma jednak zrobić ktoś, kto chciałby wykorzystać Arduino w swoim projekcie na poważnie? Komunikacja z Arduino z poziomu własnej aplikacji jest możliwa, jednak nie jest to już takie łatwe i oczywiste, jak by mogło się wydawać po lekturze działu „transmisja szeregowa”.

W poniższym artykule postaram się opisać niezbędne elementy umożliwiające wymianę danych z mikrokontrolerem, problemy związane z transmisją danych oraz sposoby ich rozwiązania. Na koniec przedstawię przykładowe rozwiązanie komunikacji czyli kod aplikacji dla Arduino oraz program dla PC.

UWAGA: Jeżeli jesteś niecierpliwy, skopiowałeś kody programów i ... nie działa... zapoznaj się z drugą częścią, gdzie opisuję pewne istotne różnice w komunikacji pomiędzy różnymi wersjami Arduino (istotny jest układ, który łączy Arduino z PC-tem) - przejdź do częśći II (znane problemy)

Po tym przydługim wstępie pora na konkrety. Załóżmy, że opracowaliśmy rewelacyjny układ na bazie Arduino, który gromadzi i przetwarza informacje z czujników oraz może je przesłać do komputera w celu ich archiwizacji. Sprawa wydaje się dosyć prosta. Wystarczy napisać program dla Arduino, który odczyta dane z czujników i wyśle je do komputera. Z pozycji naszego PC-ta należy odebrać dane i je zinterpretować. No właśnie… Potrzebna jest aplikacja dla komputera, która odczyta dane i wykorzysta je do własnych celów. I tu pojawia się pierwszy problem: jaki system operacyjny mamy zainstalowany na komputerze. Zdecydowana większość użytkowników domowych korzysta z systemu operacyjnego Windows. Ja osobiście korzystam z Linuksa. Jeżeli chcemy, aby nasz projekt mógł być wykorzystany przez innych, musimy zadbać o to, by można go było uruchomić przynajmniej na tych dwóch rodzinach systemów operacyjnych. Z pomocą przyjdzie nam biblioteka, która umożliwia obsługę portów szeregowych zarówno w systemie Windows jak i Linux. Ja osobiście polecam bibliotekę serialib, którą testowałem w obu systemach i funkcjonowała poprawnie. Transmisję szeregową możemy obsłużyć oczywiście sami bez wykorzystania biblioteki, ale wtedy musimy poznać dokładnie sposób konfiguracji portów szeregowych, a biblioteka udostępnia nam gotowy zestaw metod.

Kolejny element niezbędny do wykonania połączenia pomiędzy mikrokontrolerem, a komputerem, to ustalenie typów i ilości danych przesyłanych pomiędzy urządzeniami. Możemy ustalić, że wysłanie np. znaku „a��� to zapytanie o wartość pierwszego czujnika, a wysłanie przez aplikację komputera znaku „b” to zapytanie o wartość drugiego czujnika. W ten sposób możemy opisać dowolną liczbę danych do przesłania. Jest to jednak sposób mało elastyczny i czytelny. Zdecydowanie polecam inne rozwiązanie. Do przesyłania danych pomiędzy urządzeniami stosuję struktury, czyli elementy o „skomplikowanej” budowie zapewniające dostęp do dowolnych danych przy stosunkowo prostym programie do obsługi transmisji.

Poniżej pokazano przykładową strukturę wykorzystywaną do transmisji danych.

struct ramka{
    char kod_rozkazu;
    char parametr_rozkazu;
    char dane;
    } rozkaz;

Jak widać nasza struktura składa się z kilku danych, które posiadają swoje nazwy i typy (osoby, które wcześniej nie spotkały się ze strukturami w programowaniu powinny zapoznać się z naszym działem „programowanie/C++/struktury”). Przesyłanie struktury ma prawie same zalety. Wysyłamy zawsze paczkę o tym samym rozmiarze, wypełniamy wartościami tylko te pola, które są nam potrzebne, odczytujemy wartości poprzez konkretne ustalone nazwy, funkcje obsługujące transmisję są proste w implementacji. Wadą takiego rozwiązania jest wysyłanie większej ilości danych niż jest to niezbędne (cała struktura zamiast np. jednego bajtu).

Dysponując wiedzą teoretyczną możemy przystąpić do napisania programów do obsługi transmisji szeregowej. Na wstępie ustalimy szczegółowe założenia projektu:

  • do Arduino podłączymy termometr DS18B20, z którego odczytamy temperaturę otoczenia,
  • z poziomu komputera będziemy mogli sterować dowolnym pinem układu Arduino,
  • z poziomu komputera będziemy mogli odczytać stan dowolnego pinu układu Aruino (w tym analogowe wejścia).

Poniżej znajduje się schemat układu, który możemy wykorzystać do testowania programu. Do pinu 10 podłączymy linię danych termometru DS18B20, do portu A0 podłączymy potencjometr, z którego odczytamy wartość napięcia.

 

Znając wymagania dla aplikacji możemy przejść do zaprojektowania niezbędnej struktury danych.

struct ramka{
    char kod_rozkazu;
    byte parametr_rozkazu;
    unsigned int stan_pinu;
    byte tempL;
    byte tempH;
    } rozkaz;

Pole kod_rozkazu będzie mogło zawierać jedną z wartości: R, H, L, A, T (R - odczyt danych cyfrowych, L – ustawienie stanu niskiego, H – ustawienie stanu wysokiego, A – odczyt danych analogowych, T – odczyt temperatury), parametr_rozkazu to liczba wskazująca na numer portu, stan_pinu to wartość odczytana z portu, a temperatura to wartość odczytana z termometru.

Powyższą strukturę wykorzystamy zarówno w aplikacji dla Arduino, jak również w programie uruchomionym na naszym komputerze. Przejdziemy teraz do napisania aplikacji dla Arduino. Układ ma odczytywać temperaturę, więc musimy załączyć odpowiednią bibliotekę oraz zadeklarować zmienne niezbędne do zapamiętania wartości temperatury.

#include <OneWire.h>
    OneWire ds(10); //termometr podłączony do pinu 10
    int l,h; //młodszy i starszy bajt wartości temperatury

W funkcji setup ustawiamy tylko prędkość transmisji:

void setup(){
    Serial.begin(9600);
}

Funkcja loop sprowadza się do cyklicznego odczytu temperatury z układu DS18B20:

void loop(){
    ds.reset();
    ds.write(0xcc);
    ds.write(0x44);
    delay(800);
    ds.reset();
    ds.write(0xcc);
    ds.write(0xbe);
    l=ds.read();
    h=ds.read();
    ds.reset();
}

Całą właściwą obsługę komunikacji z pozycji Arduino oprogramujemy w funkcji serialEvent (patrz – Arduino/transmisja szeregowa). SerialEvent umożliwia wykonanie fragmentu kodu w sytuacji, gdy dane znajdują się w buforze. Dokumentacja Arduino podaje, że bufor może mieć maksymalnie 64 bajty. Jest to ważne dla naszego przykładu, ponieważ musimy mieć świadomość, że dowolna rozbudowa struktury z przykładu jest niemożliwa. Jesteśmy ograniczeni rozmiarem 64 bajtów, które możemy jednocześnie przesłać. Zdecydowana większość projektów zamknie się w powyższym rozmiarze bufora. W sytuacji, gdy ilość danych do przesłania będzie większa, należy podzielić dane na 2, 3 części i wysłać je kolejno…

Wracając do naszego przykładu. Ilość danych do przesłania jest mniejsza niż rozmiar bufora wejściowego Arduino, możemy więc poczekać na przesłanie całej ramki i odczytać ją za jednym razem.

void serialEvent(){
    if(Serial.available()==sizeof(ramka))
     {
      Serial.readBytes(ro,sizeof(rozkaz));
      memcpy(&rozkaz,ro,sizeof(rozkaz));
     //ciąg dalszy obsługi transmisji……..
     }
}

Jak widać na przykładzie powyżej sprawdzamy, czy w buforze znajduje się już cała odczytana ramka i dopiero wtedy następuje odczyt danych. Funkcja Serial.readBytes umożliwia odczytanie danych do bufora znakowego (tablicy znaków – char). Niezbędne jest więc przekopiowanie danych z bufora do naszej zdefiniowanej struktury. Funkcja memcpy umożliwia skopiowanie obszaru pamięci zajmowanego przez jeden obiekt w tym wypadku tablicę znaków w miejsce zajmowane przez drugi obiekt (naszą strukturę).

Teraz możemy zająć się obsługą przesłanego polecenia. Należy sprawdzić pole kod_rozkazu i wykonać odpowiednią akcję.

if(rozkaz.kod_rozkazu=='H')
 {
  pinMode(rozkaz.parametr_rozkazu,OUTPUT);
  digitalWrite(rozkaz.parametr_rozkazu,HIGH);
 }
if(rozkaz.kod_rozkazu=='L')
 {
  pinMode(rozkaz.parametr_rozkazu,OUTPUT);
  digitalWrite(rozkaz.parametr_rozkazu,LOW);
  }
if(rozkaz.kod_rozkazu=='R')
 {
  rozkaz.stan_pinu=digitalRead(rozkaz.parametr_rozkazu);
 }
if(rozkaz.kod_rozkazu=='A')
 {
  rozkaz.stan_pinu=analogRead(rozkaz.parametr_rozkazu);
 }
if(rozkaz.kod_rozkazu=='T')
 {
  rozkaz.tempL=l;
  rozkaz.tempH=h;
 }

Jak widać oprogramowanie poszczególnych kodów jest proste, przejrzyste i co najważniejsze łatwo nasz program rozbudować o kolejne funkcje. Ostatnim elementem programu dla Arduino jest wysłanie odpowiedzi do komputera. Zrealizujemy to z wykorzystaniem dwóch poleceń. W pierwszym kroku musimy przekopiować zawartość struktury do tablicy znaków. Wykorzystamy do tego funkcję memcpy. Następnie prześlemy zawartość bufora do komputera.

 memcpy(ro,&rozkaz,sizeof(rozkaz));
 Serial.write(ro,sizeof(rozkaz));

W tym momencie dysponujemy kompletnym programem dla Arduino, który umożliwi wykonanie zamierzonych celów. Poniżej kompletny listing programu dla Arduino.

#include <OneWire.h>
OneWire ds(10);

struct ramka{
    char kod_rozkazu;
    byte parametr_rozkazu;
    unsigned int stan_pinu;
    byte tempL;
    byte tempH;
    } rozkaz;

int l,h;
char ro[sizeof(rozkaz)];

void setup(){
    Serial.begin(9600);
    }

void loop(){
    ds.reset();
    ds.write(0xcc);
    ds.write(0x44);
    delay(800);
    ds.reset();
    ds.write(0xcc);
    ds.write(0xbe);
    l=ds.read();
    h=ds.read();
    ds.reset();
    }

void serialEvent(){
    if(Serial.available()==sizeof(rozkaz))
     {
      Serial.readBytes(ro,sizeof(rozkaz));
      memcpy(&rozkaz,ro,sizeof(rozkaz));
      if(rozkaz.kod_rozkazu=='H')
       {
        pinMode(rozkaz.parametr_rozkazu,OUTPUT);
        digitalWrite(rozkaz.parametr_rozkazu,HIGH);
       }
      if(rozkaz.kod_rozkazu=='L')
       {
        pinMode(rozkaz.parametr_rozkazu,OUTPUT);
        digitalWrite(rozkaz.parametr_rozkazu,LOW);
       }
      if(rozkaz.kod_rozkazu=='R')
       {
        rozkaz.stan_pinu=digitalRead(rozkaz.parametr_rozkazu);
       }
      if(rozkaz.kod_rozkazu=='A')
       {
        rozkaz.stan_pinu=analogRead(rozkaz.parametr_rozkazu);
       }
      if(rozkaz.kod_rozkazu=='T')
       {
        rozkaz.tempL=l;
        rozkaz.tempH=h;
        }
      memcpy(ro,&rozkaz,sizeof(rozkaz));
      Serial.write(ro,sizeof(rozkaz));
     }
    }

Pora zająć się programem dla komputera PC. Podstawowym zadaniem naszego przykładowego programu będzie wysłanie polecenia do Arduino oraz wyświetlenie otrzymanej odpowiedzi. Ze względu na edukacyjny charakter aplikacji zostanie ona napisana w czystym C++, bez wykorzystania API systemu Linux/Windows. Dzięki temu aplikacja będzie w miarę przejrzysta. Oczywiście zdecydowanie lepiej wygląda aplikacja okienkowa, ale to jest temat na inny artykuł.

Pisanie aplikacji dla PC rozpoczynamy od pobrania biblioteki serialib, pliki serialib.cpp oraz serialib.h kopiujemy do nowego katalogu, w którym będzie nasz projekt. Korzystając z notatnika (dla systemu Windows polecam Notepad++) tworzymy nowy plik tekstowy z rozszerzeniem .cpp np. sterownik.cpp. W tym momencie możemy przystąpić do napisania właściwej aplikacji. W pierwszym kroku załączamy bibliotekę srialib, z której będziemy korzystać. Następnie ustawiamy numer portu szeregowego, do którego podłączone jest nasze Arduino. Niestety na razie nie będziemy mieć możliwości automatycznego wykrycia urządzenia. Dodatkowo w zależności od systemu operacyjnego mamy do dyspozycji:

  • Windows: com1, com2, com3 …
  • Linux: /dev/ttyUSB0, /dev/ttyUSB1 …, /dev/ttyACM0, /dev/ttyACM1,

Port najłatwiej sprawdzić z poziomu ArduinoIDE (Narzędzia/Port). Poniżej fragment programu dla naszego PC-ta.

#include <stdio.h>
#include "serialib.h"
#include <iostream>

#if defined (_WIN32) || defined( _WIN64)
#define DEVICE_PORT "COM1"
#endif
#ifdef __linux__
#define DEVICE_PORT "/dev/ttyUSB0"
#endif

Definicja portu uzależniona jest od wykrytego systemu operacyjnego. Warto pozostawić definicję dla obu systemów, choć można ograniczyć powyższy zapis do jednej linii:

#define DEVICE_PORT "nazwa_portu_naszego_systemu"

Kolejny fragment przygotowuje do do podłączenia naszej aplikacji z Arduino.

struct ramka{
    char kod_rozkazu;
    unsigned char parametr_rozkazu;
    unsigned short stan_pinu;
    unsigned char tempL;
    unsigned char tempH;
   } rozkaz;

Struktura naszego komunikatu musi być identyczna jak struktura w programie dla Arduino.

Uwaga!! Typy danych w C++ oraz w Arduino różnią się rozmiarem. Wybierając typ danych należy sprawdzić, czy jest on tak samo reprezentowany w obu systemach. Porównując strukturę z programu dla Arduino oraz strukturę z aplikacji w C++ zauważymy różnice w nazwach typów poszczególnych pól. Wynika to właśnie z różnej wielkości typów. Przykładowo int w Arduino to 2 bajty, w C++ zazwyczaj 4. Odpowiednikiem typu word jest unsigned short.

Właściwy program będzie otwierał połączenie ze sterownikiem, wyśle wypełnioną strukturę do Arduino, odbierze dane i zamknie połączenie. Uproszczony schemat programu będzie wyglądał następująco:

Arduino.Open(DEVICE_PORT,SPEED);
Arduino.Write(Buffer,SIZE);
Arduino.Read(Buffer,SIZE);
Arduino.Close();

Aplikacja na komputerze będzie uruchamiana z parametrem, lub parametrami. W zależności od podanych parametrów zostanie wysłane odpowiednie polecenie do sterownika. Za interpretację poszczególnych parametrów odpowiada poniższy fragment programu:

if(argc==3)
   {
    if(strcasecmp(argv[1],"On")==0)
     {rozkaz.kod_rozkazu='H';rozkaz.parametr_rozkazu=atoi(argv[2]);}
    if(strcasecmp(argv[1],"Off")==0)
     {rozkaz.kod_rozkazu='L';rozkaz.parametr_rozkazu=atoi(argv[2]);}
    if(strcasecmp(argv[1],"AIN")==0)
     {rozkaz.kod_rozkazu='A';rozkaz.parametr_rozkazu=atoi(argv[2]);}
    if(strcasecmp(argv[1],"DIN")==0)
     {rozkaz.kod_rozkazu='R';rozkaz.parametr_rozkazu=atoi(argv[2]);}
   }
if(argc==2)
   {
    if(strcasecmp(argv[1],"TEMP")==0){rozkaz.kod_rozkazu='T';}
   }

Po wypełnieniu poszczególnych elementów struktury następuje transmisja danych. Za wysłanie danych odpowiada fragment:

memcpy(Buffer,&rozkaz,sizeof(rozkaz));
Arduino.Write(Buffer,sizeof(rozkaz));

W odpowiedzi na wysłany komunikat Arduino wysyła zamówione dane. Aplikacja odbiera ramkę i wyświetla je użytkownikowi.

Arduino.Read(Buffer,sizeof(rozkaz),2000);
memcpy(&rozkaz,Buffer,sizeof(rozkaz));
if(rozkaz.kod_rozkazu=='A')
   {
    cout << "Odczyt z portu A" << (int)rozkaz.parametr_rozkazu << " : ";
    cout << rozkaz.stan_pinu << "
";
   }
if(rozkaz.kod_rozkazu=='R')
   {
    cout << "Stan pinu "<<(int)rozkaz.parametr_rozkazu << " : ";
    if(rozkaz.stan_pinu)
       cout << "WYSOKI (HIGH)
";
    else
       cout << "NISKI (LOW)
";
   }
if(rozkaz.kod_rozkazu=='T')
   {
    cout << "Odczytana temperatura : ";
    cout << (float)rozkaz.tempL/16+rozkaz.tempH*16 << "ºC
";
   }

Kompletny funkcjonujący kod aplikacji przedstawiono poniżej:

#include <stdio.h>
#include "serialib.h"
#include <iostream>

#if defined (_WIN32) || defined( _WIN64)
#define DEVICE_PORT "COM1"
#endif
#ifdef __linux__
#define DEVICE_PORT "/dev/ttyUSB0"
#endif

struct ramka{
    char kod_rozkazu;
    unsigned char parametr_rozkazu;
    unsigned short stan_pinu;
    unsigned char tempL;
    unsigned char tempH;
   } rozkaz;

using namespace std;

int main(int argc, char *argv[])
   {
    serialib Arduino;
    unsigned short Ret;
    char Buffer[40];
    float t;

    if((argc==2)||(argc==3))
     {
      Ret=Arduino.Open(DEVICE_PORT,9600);
      if (Ret!=1)
       {
        printf ("Nie mogę otworzyć portu!
");
        return Ret;
       }
      if(argc==3)
       {
        if(strcasecmp(argv[1],"On")==0)
          {rozkaz.kod_rozkazu='H';rozkaz.parametr_rozkazu=atoi(argv[2]);}
        if(strcasecmp(argv[1],"Off")==0)
          {rozkaz.kod_rozkazu='L';rozkaz.parametr_rozkazu=atoi(argv[2]);}
        if(strcasecmp(argv[1],"AIN")==0)
          {rozkaz.kod_rozkazu='A';rozkaz.parametr_rozkazu=atoi(argv[2]);}
        if(strcasecmp(argv[1],"DIN")==0)
          {rozkaz.kod_rozkazu='R';rozkaz.parametr_rozkazu=atoi(argv[2]);}
       }
      if(argc==2)
        {
         if(strcasecmp(argv[1],"TEMP")==0){rozkaz.kod_rozkazu='T';}
        }
        memcpy(Buffer,&rozkaz,sizeof(rozkaz));
        Arduino.Write(Buffer,sizeof(rozkaz));
        Arduino.Read(Buffer,sizeof(rozkaz),2000);
        memcpy(&rozkaz,Buffer,sizeof(rozkaz));
        if(rozkaz.kod_rozkazu=='A')
         {
          cout << "Odczyt z portu A"<< (int)rozkaz.parametr_rozkazu;
          cout << " : " << rozkaz.stan_pinu << "
";
         }
        if(rozkaz.kod_rozkazu=='R')
          {
           cout << "Stan pinu "<<(int)rozkaz.parametr_rozkazu << " : ";
           if(rozkaz.stan_pinu)
              cout << "WYSOKI (HIGH)
";
           else
              cout << "NISKI (LOW)
";
          }
        if(rozkaz.kod_rozkazu=='T')
         {
          cout << "Odczytana temperatura : ";
          cout << (float)rozkaz.tempL/16+rozkaz.tempH*16<< "ºC
";
         }
       Arduino.Close();
     }
    else
     {
      cout  << "Program sterujący  - komunikacja z Arduino
";
      cout  << "Program uruchamiamy z jednym lub z dwoma parametrami:
";
      cout  << "sterownik TEMP - odczyt temperatury
";
      cout  << "sterownik DIN nr_pinu - odczyt stanu pinu z Arduino
";
      cout  << "sterownik AIN nr_pinu - odczyt portu analogowego
";
      cout  << "sterownik On nr_pinu - ustawienie stanu wysokiego 
";
      cout  << "sterownik Off nr_pinu - ustawienie stanu niskiego 
";
     }
    return 0;
   }

Jak widać program nie jest długi i skomplikowany, wykonuje to co do niego należy i możemy go dowolnie rozbudowywać. Warto zwrócić uwagę na kilka drobiazgów w samym kodzie programu. Otwarcie portu szeregowego nie zawsze jest możliwe, dlatego metoda Arduino.open znajduje się w warunku i może spowodować zakończenie programu z komunikatem błędu. Dodatkowo na początku programu sprawdzana jest ilość podanych parametrów. Jeżeli użytkownik poda jeden, lub dwa parametry, to wykonuje się dalsza część programu (komunikacja). W przypadku, gdy program zostanie uruchomiony bez parametrów, lub ze zbyt dużą ich ilością zostanie wyświetlona informacja o programie i sposobie jego użycia. Pisząc ten przykładowy program zakładałem dobrą wolę użytkownika, dlatego nie ma w programie kontroli wprowadzonych parametrów. Nie jest to trudne w implementacji, ale troszkę by skomplikowało kod całego programu. Przykładowy program nie obsługuje również analogowego wyjścia (czyli sterowania PWM), co proponuję dopisać w ramach "zadania domowego".