Witam,
Do tej pory na majsterkowie byłem tylko czytelnikiem, teraz nadszedł czas, żeby się spróbować jako autor. Podkreślam jednak, że nie mam dużego doświadczenia w obsłudze mikrokontrolerów, a języka C++ uczyłem się dawno i na co dzień go nie wykorzystuję.
Wstęp
Zabawę z Arduino zazwyczaj rozpoczyna się od migania diodą, brzęczenia buzzerem czy sprawdzania innych przykładów. W pewnym momencie jednak przychodzi czas na większe i poważniejsze projekty, np. może nam przyjść ochota na zbudowanie jakiegoś robota lub pojazdu. W takich, oraz innych przypadkach pojawia się potrzeba zdalnego sterowania naszym dziełem. Kiedy stanąłem przed takim wyzwaniem, pomyślałem, że wygodnie będzie zbudować sobie kontroler w obudowie od pada do PlayStation. Początkowo planowałem wyrzucić całe wnętrze i stworzyć je od podstaw, ale po co wyważać otwarte drzwi? Po krótkich poszukiwaniach odnalazłem opis gotowej biblioteki do obsługi pada od PS2. Do tego wystarczy dodać transmisję bezprzewodową, kilka elementów elektronicznych, trochę kodu i gotowe! Nie musimy się martwić o problemy związane np. z dokładnością pomiarów analogowych, zjawiskiem drgania styków itp. To robi za nas pad, a my tylko przechwytujemy wartości, opakowujemy po swojemu i wysyłamy do naszego odbiornika.
W Botlandzie można kupić gotowe urządzenie, ale ma ono zasięg do ok 8 metrów, co w większości przypadków może być niewystarczające. W moim projekcie w zależności od użytego nadajnika i odbiornika możemy uzyskać nawet kilkaset metrów zasięgu.
Założenia
- korzystam z tanich elementów do komunikacji radiowej – opisane np. na https://majsterkowo.pl/jak-zaczac-z-rf/
- prototypuję na Arduino…
- … ale później projektuję i tworzę własną płytkę PCB, opartą na mikrokontrolerze ATmega
- staram się pisać optymalny i porządny kod (postaram się przekazać kilka wskazówek w tym temacie)
- kontroler ma być uniwersalny, wysyła stan przycisków i drążków, decyzja co później z nimi zrobić leży po stronie odbiornika
- kontroler musi być “sparowany” z odbiornikiem, tak aby przy użyciu 2 kontrolerów można było niezależnie sterować 2 odbiornikami (wybrany przeze mnie sposób komunikacji nie pozwala na użycie osobnych kanałów)
Lista potrzebnych elementów
- pad do PS2 (oryginalne, nawet używane, są dość drogie, kupiłem zamiennik za 15 zł, który niestety nie jest najwyższej jakości)
- ATmega 8 lub mocniejsza
- nadajnik i odbiornik RF
- drobna elektronika (rezystor 10kΩ, rezystor 220Ω, dławik 10μH, kondensatory 100nF, kondensator elektrolityczny 22μF, dioda) – polecam kupić większą ilość. Są to elementy tanie i potrzebne do większości projektów.
Lista przydatnych elementów
- dowolne Arduino do prototypowania oraz ewentualnie programator USBasp ISP
- dodatki typu kabelki, płytka stykowa, gniazdo ISP 10 Kanda, łączniki, goldpiny itp.
- multimetr – bardzo przydatne narzędzie, nawet w najtańszej wersji
- narzędzia i elementy potrzebne do wykonania płytki PCB – nie będę tutaj omawiał wszystkich szczegółów, na majsterkowie jest sporo informacji na ten temat – np. tutaj i tutaj
Schemat
Bez zbędnej zwłoki przejdźmy do zaprojektowania schematu. W przypadku Arduino sprawa jest prosta, ponieważ w wersji minimalistycznej potrzebujemy tylko podłączyć przewody od pada oraz nadajnik RF.
W przypadku przeniesienia na mikrokontroler, potrzebujemy więcej elementów – musimy zadbać o poprawne podłączenie i filtrowanie. Jest to o tyle ważne, że brak filtrowania może negatywnie wpływać na działanie nadajnika. W moim przypadku filtruję nieco na wyrost, ponieważ nie będę używać przetwornika analogowo-cyfrowego, ale taki sposób podłączenia nie zaszkodzi, a w przyszłości może pozwolić na rozbudowę i np. bezpośrednie podpięcie się pod drążki i przyciski.
W tym miejscu miałem wstawić schemat podłączenia z programu Fritzing przy użyciu mikrokontrolera wraz z filtrowaniem, ale mimo że starałem się sensownie go narysować, to za każdym razem wychodził bardzo nieczytelnie. Jeżeli ktoś jest bardzo zainteresowany, to wstawiam jako odnośnik do obrazka, ale podkreślam że nie do końca można na nim polegać. Przede wszystkim kondensatory powinny być bliżej nóżek mikrokontrolera, zdecydowanie polecam obejrzenie schematu ideowego oraz projektu płytki PCB, umieszczonych niżej (Przygotowanie PCB).
Przed podłączeniem przewodów polecam ustalić ich kolejność, przez sprawdzenie który kolor łączy się z wyprowadzeniem we wtyczce:
Praca z kodem
Zacznijmy od dołączenia do projektu dwóch potrzebnych bibliotek:
- PS2X – https://github.com/madsci1016/Arduino-PS2X
- VirtualWire – http://www.airspayce.com/mikem/arduino/VirtualWire/
Uwaga. Biblioteka VirtualWire nie jest już rozwijana i została zastąpiona przez RadioHead – http://www.airspayce.com/mikem/arduino/RadioHead/. Ponieważ jednak VirtualWire jest nieco mniejsza (a na początku miałem problem ze zmieszczeniem się na ATmega 8) to zdecydowałem się ją zostawić. Zachęcam do przejścia na nowszą i wspieraną bibliotekę. W kolejnych projektach na pewno spróbuję z niej skorzystać.
Biblioteki po pobraniu można dodać do dostępnych np. przez Arduino IDE Szkic→Dołącz bibliotekę→Dodaj bibliotekę .ZIP a następnie wskazać te biblioteki i dołączyć do programu. Na samym początku powinny pojawić się linijki:
1 2 3 |
#include <PS2X_lib.h> #include <VirtualWire.h> #include <VirtualWire_Config.h> |
Jak ma działać kontroler?
Kilka założeń:
- nie wysyłam stanu przycisków w każdej pętli, robię to tylko jeżeli coś się zmieniło – dzięki temu zdecydowanie ograniczam wykorzystanie radia, wydłużam czas pracy na baterii i daję więcej czasu na działanie odbiornikowi
- wysyłam tylko te wartości które uległy zmianie – ilość danych które mogę przesłać za jednym razem jest mocno ograniczona, najlepiej wysłać wszystko co potrzebne za jednym razem
- wprowadzam pewnego rodzaju “pseudo-kanał” aby dany nadajnik współpracował z wybranym odbiornikiem
- sprawdzam czy odbiornik jest w zasięgu działania nadajnika – wyobraźmy sobie sytuację, kiedy np. odbiornik jest zdalnie sterowanym samochodem. Zaczynamy jechać do przodu i samochód wyjeżdża poza nasz zasięg. Nie możemy już nim sterować, a on ciągle ma włączoną jazdę przed siebie i oddala się coraz bardziej. Dlatego dbam, żeby maksymalnie co ok. sekundę był wysyłany sygnał. Jeżeli odbiornik go nie otrzyma w zadanym czasie, to może zareagować (np. samochód się zatrzyma).
Sprawdzam jak działa biblioteka oraz jakie metody mi udostępnia i znajduję które będą mi potrzebne:
- najpierw należy stworzyć instancję klasy PS2X
- config_gamepad – wywoływana raz w setup() umożliwia podłączenie się do pada i jego konfigurację
- read_gamepad – wywoływana w każdym przebiegu pętli odczytuje wartości przycisków i drążków
- NewButtonState – pozwala sprawdzić czy zmienił się stan dowolnego lub konkretnego przycisku
- Analog – pozwala odczytać wartości drążków
Całkiem nieźle, ale brakuje mi kilku rzeczy. Przede wszystkim nie jestem w stanie sprawdzić czy zmieniły się wychylenia drążków. Dodatkowo chciałbym sformatować wartości przed wysłaniem, tak żeby ułatwić sobie ich interpretację po stronie odbiornika. W związku z tym sensownym jest rozszerzenie klasy PS2X o własny kod.
TIP 1
Teoretycznie można dopisać kod bezpośrednio do biblioteki, ale nie jest to dobry pomysł. Przede wszystkim utrudni to zarządzanie własnym kodem a dodatkowo spowoduje problemy przy ewentualnym aktualizowaniu biblioteki do nowszej wersji. Zamiast tego skorzystamy z dziedziczenia. Tworzymy klasę Controller, przez którą będzie dostęp do wszystkich metod i pól publicznych w klasie PS2X
Zanim przejdziemy do napisania klasy, trzeba ustalić jakiś “protokół” wysyłania danych. Mamy 16 przycisków i 4 wartości dla drążków (wychylenie góra-dół i lewo-prawo dla każdego). Wymyśliłem, że każdej wartości przypiszę dwuliterowy skrót (np. se dla przycisku Select) i po dwukropku podam stan, a poszczególne rozdzielę pionową kreską | (ang. pipe). Na początku będzie wysłany “kanał” na którym działa nadajnik (dla odróżnienia skrótem jest jedna litera – c). Przykładowa wiadomość do wysłania może wyglądać następująco:
1 |
c:1|se:0|up:1|sq:1|ly:255| |
i znaczy to: kanał 1, select został puszczony, strzałka w górę została wciśnięta, kwadrat został wciśnięty, położenie lewej gałki w osi y zmieniło się na 255.
Biblioteka udostępnia nam przyciski pod nazwami np. PSB_SELECT, PSB_SQUARE, PSS_LY itd, więc musimy je sobie jakoś przetłumaczyć na nasze nazwy skrócone. Najlepszy byłby słownik przechowujący te dane w postaci klucz – wartość, czyli tzw. hash table. Niestety musielibyśmy do tego wykorzystać dodatkowe biblioteki, albo samemu to oprogramować. W tym wypadku szybciej i bardziej wydajnie będzie zastosować 2 osobne tablice. Musimy tylko zadbać o poprawną kolejność i identyczną ilość elementów w obu. A nawet dwa takie zestawy, ponieważ osobno potraktujemy przyciski i gałki.
Nie przedłużając przedstawię kod całej klasy z dość rozbudowanymi komentarzami
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 |
/****************************************************************** * class Controller - tworzy nową klasę * podanie dwukropka a następnie słowa kluczowego public * i nazwy innej klasy oznacza że dziedziczymy wszystkie metody * i pola publiczne z tej klasy. Innymi słowy klasa Contrloller * będzie udostępniała wszystko to co mogliśmy użyć w klasie PS2X * a dodatkowo możemy ją rozszerzyć o swój kod ******************************************************************/ class Controller : public PS2X { //słowo kluczowe private oznacza że poniższe elementy są dostępne //jedynie wewnątrz klasy. Przechowujemy tutaj rzeczy, które używamy //wewnętrznie i kod spoza klasy nie powinien mieć do nich bezpośredniego //dostępu, zapewniając odpowiednią hermetyzację klasy private: //deklarujemy 4 zmienne do zapamiętania ostatniego położenia drążków //znak _ na początku nazwy umownie oznacza zmienną prywatną //na początku ustawiam wartość 128, ponieważ taka jest w położeniu neutralnym byte _lastRx = 128; byte _lastRy = 128; byte _lastLx = 128; byte _lastLy = 128; //wychylenie drążków przyjmuje wartości od 0 do 255 więc do przechowania //idealnie nadaje się typ byte - https://www.arduino.cc/en/Reference/Byte //słowo kluczowe public oznacza że poniższe elementy są dostępne //zarówno wewnątrz, jak i na zewnątrz klasy public: //definiuję stałą określającą ile przycisków jest opisanych w obu tablicach, //mógłbym po prostu wpisać 16 przy obu deklaracjach ale ten sposób //zabezpieczam się przed podaniem różnych wartości. static const byte buttons_count = 16; //tworzę tablicę z przyciskami, używam tego samego typu co w bibliotece //z której dziedziczę unsigned int buttons_keys[buttons_count] = { PSB_SELECT, PSB_L3, PSB_R3, PSB_START, PSB_PAD_UP, PSB_PAD_RIGHT, PSB_PAD_DOWN, PSB_PAD_LEFT, PSB_L2, PSB_R2, PSB_L1, PSB_R1, PSB_TRIANGLE, PSB_CIRCLE, PSB_CROSS, PSB_SQUARE }; //tworzę stałą (słowo kluczowe const) tablicę typu char z wartościami //do wysłania. 5 w drugim nawiasie kwadratowym oznacza długość każdego //elementu. Tak naprawdę przyjmują jednak 4 znaki, ponieważ char jest //zakończony znakiem \0 oznaczającym właśnie jego koniec const char buttons_values[buttons_count][5] = { "|se:", // PSB_SELECT, "|l3:", // PSB_L3, "|r3:", // PSB_R3, "|st:", // PSB_START, "|up:", // PSB_PAD_UP, "|rt:", // PSB_PAD_RIGHT, "|dn:", // PSB_PAD_DOWN, "|lt:", // PSB_PAD_LEFT, "|l2:", // PSB_L2, "|r2:", // PSB_R2, "|l1:", // PSB_L1, "|r1:", // PSB_R1, "|tr:", // PSB_TRIANGLE, "|ci:", // PSB_CIRCLE, "|cr:", // PSB_CROSS, "|sq:" // PSB_SQUARE }; //analogicznie tworzę tablicę dla drążków, tutaj już nie tworzyłem //stałej określającej rozmiar - tablice są mniejsze i trudniej o pomyłkę unsigned int sticks_keys[4] = { PSS_RX, PSS_RY, PSS_LX, PSS_LY }; //analogicznie stała tablica char z wartościami const char sticks_values[4][5] = { "|rx:", // PSS_RX, "|ry:", // PSS_RY, "|lx:", // PSS_LX, "|ly:" // PSS_LY }; //czas na pierwszą metodę. Będę ją wywoływał na końcu pętli, żeby zapisać aktualne //położenie drążków void SaveSticksValues() { _lastRx = Analog(PSS_RX); _lastRy = Analog(PSS_RY); _lastLx = Analog(PSS_LX); _lastLy = Analog(PSS_LY); } //ta metoda zwraca wartość typu boolean, czyli prawda lub fałsz (1 lub 0). Sprawdza ona czy aktualne //położenie któregokolwiek drążka zmieniło się względem ostatniego przebiegu głównej pętli boolean StickValueChanged() { //porównuję obecne wartości z poprzednio zapisanymi, jeżeli którekolwiek się zmieniły wysyłam wartość true if (Analog(PSS_RX) != _lastRx || Analog(PSS_RY) != _lastRy || Analog(PSS_LX) != _lastLx || Analog(PSS_LY) != _lastLy) return true; else return false; } //definicja tej metody jest niemal identyczna jak powyższej, różni się tym, że przyjmuje jeden parametr //wykorzystuję tzw. przeciążenie metody. Obie nazywają się tak samo i to która zostanie użyta zależy //od tego czy w momencie wywołania zostanie podany parametr. W tej wersji sprawdzamy czy wartość //wychylenia konkretnego drążka uległa zmianie boolean StickValueChanged(byte stick) { //sprawdzamy jaka wartość została przekazana do sprawdzenia i zwracamy //true jeśli zmieniła się względem poprzedniego przebiegu pętli switch (stick) { case PSS_RX: return Analog(PSS_RX) != _lastRx; break; case PSS_RY: return Analog(PSS_RY) != _lastRy; break; case PSS_LX: return Analog(PSS_LX) != _lastLx; break; case PSS_LY: return Analog(PSS_LY) != _lastLy; break; default: return false; } } }; |
Klasa gotowa. W zasadzie można ją nawet umieścić w osobnym pliku i włączyć do projektu przy użyciu tzw. pliku nagłówkowego, nie będę tego robić w tym przypadku.
Czas na ustawienie kilku stałych i zmiennych, oraz definicji pinów, które będą potrzebne do działania programu. Definiujemy je prawie na samym początku kodu (zaraz po włączeniu bibliotek), tak żeby działały jako globalne, czyli były dostępne z dowolnego miejsca
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
//ustawiam wartość "kanału" na którym będzie pracował nadajnik //dzięki temu w odbiorniku będę mógł reagować na określone sygnały #define channel "1" //co określony czas trzeba wysłać wiadomość, żeby odbiornik wiedział //że nadal jest w zasięgu. Będzie to "ping" #define ping "ping" /****************************************************************** * definicje pinów, czyli określamy pod którą nóżką mikrokontrolera * jest podłączony dany element. W zależności od naszego połączenia * wartości mogą być różne. Poniższe przy podłączeniu pod ATmega 8 ******************************************************************/ //dioda informująca o wysyłaniu danych #define led_pin A5 //pod ten pin podłączamy nadajnik #define transmit_pin 8 //poniżej 4 piny pod które podpinamy sygnały z pada #define PS2_CLK PD4 #define PS2_ATT PD3 #define PS2_CMD PD2 #define PS2_DAT PD1 /****************************************************************** * dodatkowa konfiguracja pada. Możemy określić czy przyciski * mają się zachowywać jako analogowe (wartość zależna od siły * nacisku) czy jako cyfrowe (włączony/wyłączony). * Dodatkowo określamy czy są używane wibracje ******************************************************************/ //ustawiamy przyciski jako cyfrowe #define pressures false //w tym przypadku nie używamy wibracji #define rumble false /****************************************************************** * potrzebujemy jeszcze 2 wartości. Co ile wysyłać sygnał "ping" * oraz zmienną dzięki której będziemy wiedzieć kiedy ostatnio * taki sygnał wysłaliśmy ******************************************************************/ //sygnał będziemy wysyłać co 0,9 sekundy const long interval = 900; //w tej zmiennej będziemy zapisywać milisekundy, które upłynęły //od ostatniego wysłania. Używam unsigned long, ponieważ może //pomieścić od 0 do 4 294 967 295 // https://www.arduino.cc/en/Reference/UnsignedLong unsigned long previousMillis = 0; //zmienna w której zapiszemy czy udało się podłączyć poprawnie pad int ps2Error = 0; |
TIP 2
Bardzo często widzę definicję pinów jako np.
int ledPin = 13;
Oczywiście zadziała to bez problemów, ale nie powinno się tak robić. Przede wszystkim w 99,9% projektów nie będziemy tej wartości zmieniać (jeżeli nie wiesz czy potrzebujesz ją zmieniać, to znaczy że nie potrzebujesz) a w tej sytuacji można w trakcie programowania przypisać do niej inną wartość i program zacznie zachowywać się nieoczekiwanie. Dodatkowo taka zmienna zajmuje pamięć. Niby niewiele ale zawsze.
Jak widać powyżej, ja stosuję np.
#define ledPin 13
Plus jest taki, że wartość jest niezmienna przez czasu czas działania programu i zajmuje mniej miejsca. A tak naprawdę nie zajmuje wcale miejsca, ponieważ podczas kompilacji po prostu każde wystąpienie w kodzie ledPin jest zastępowane wartością 13.
Można jeszcze użyć słowa kluczowego const (uzyskując np. const int), które oznacza, że wartość jest stała. Zaletą tego rozwiązania jest zdefiniowanie jakiego typu jest wartość.
Dla testu napisałem prosty program, w którym zdefiniowałem 5 pinów (kolejno jako int, const int i #define), w setup() ustawiłem pinMode jako OUTPUT a w loop() je zapaliłem ustawiając stan wysoki. Wynik:
int – Program size: 756 bytes
const int – Program size: 704 bytes
#define – Program size: 704 bytes
Niektórzy mogli się dziwić dlaczego definiowałem numer kanału lub tekst ping na samej górze programu – takie rzeczy raczej się nie zmieniają i spokojnie można je podać w samym kodzie metod. Ale jak widać nie powoduje to w zasadzie żadnego narzutu na program, a dodatkowo wyodrębnia całą konfigurację w jednym miejscu. Jeżeli musimy coś zmienić, to chroni nas przed popełnieniem błędu.
Teraz utworzymy kilka metod pomocniczych, do wysyłania wiadomości, do wysłania komunikatu ping oraz do zebrania stanu przycisków, sformatowania go i przygotowania do wysyłki. W dwóch ostatnich metodach kryje się kilka ważnych rozwiązań i podjętych decyzji, więc na koniec podsumuję co tam zrobiłem.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 |
/****************************************************************** * poniższa metoda służy do wysłania wiadomości i jej * zawartość w głównej mierze pochodzi z przykładów * biblioteki VirtualWire. Dodatkowo zapalamy diodę * w trakcie wysyłki i zapisujemy aktualny czas. ******************************************************************/ void send(char message[VW_MAX_MESSAGE_LEN]) { //zapalamy diodę przed wysłaniem wiadomosci digitalWrite(led_pin, HIGH); //wysyłamy wiadomość korzystając z VirtualWire vw_send((uint8_t *)message, strlen(message)); vw_wait_tx(); //zapisujemy czas ostatnio wysłanej wiadomości //jako ilość milisekund od uruchomienia programu previousMillis = millis(); //wysyłka skończona, gasimy diodę digitalWrite(led_pin, LOW); } /****************************************************************** * wysyłanie wiadomości ping * łączymy poszczególne elementy w ciąg C:1|ping ******************************************************************/ void sendPing() { //funkcja wysyłania przyjmuję tablicę znaków - char //więc taką zmienną do niej przekażemy char msg[10]; //wartość \0 oznacza koniec danych w tablicy, //poniższa linijka powoduje że tablica jest pusta msg[0] = '\0'; //kolejno dodajemy elementy, używając funkcji strcat z C++ strcat(msg, "C:"); strcat(msg, channel); strcat(msg, "|"); strcat(msg, ping); //wysłanie wiadomości przy użyciu wcześniej utworzonej //przez nas metody send send(msg); } /****************************************************************** * W tej metodzie sprawdzamy które wartości przycisków i drążków * uległy zmianie, formatujemy z nich treść wiadomości do przesłania * i przekazujemy do metody wysyłającej ******************************************************************/ void sendValues() { //tworzymy zmienną, w której umieścimy wiadomość do wysłania //wartość VW_MAX_MESSAGE_LEN pochodzi z VirtualWire i wynosi 30 char msg[VW_MAX_MESSAGE_LEN]; //"czyścimy" zmienną ustawiając znak końca na pierwszym polu msg[0] = '\0'; //przygotowanie zmiennej w której będziemy zapisywać poszczególne //wartości przycisków i drążków char value[5]; //na początku dodajemy do treści określenie kanału C:1 strcat(msg, "C:"); strcat(msg, channel); //wartości wychylenia drążków są zapisane w 4 polach (x i y dla każdego) //więc wykonujemy czterokrotnie przebieg pętli for (int i = 0; i < 4; i++) { //w której korzystając z naszej funkcji, sprawdzamy czy //wartość danego drążka uległa zmianie if (ctrl.StickValueChanged(ctrl.sticks_keys[i])) { //jeżeli tak, to dodajemy naszą skróconą nazwę do wiadomości strcat(msg, ctrl.sticks_values[i]); //odczytujemy wartość wychylenia i przy pomocy funkcji itoa //przepisujemy ją do zmiennej value jako łańcuch znaków itoa(ctrl.Analog(ctrl.sticks_keys[i]), value, 10); //dodajemy wartość value do wiadomości strcat(msg, value); } } //analogicznie dla przycisków, przechodzimy przez listę wszystkich i dodajemy jeśli się zmieniły for (int i = 0; i < ctrl.buttons_count; i++) { //wiadomość nie może przekroczyć 30 znaków, jeżeli kolejny element nie zmieści się w wiadomości //to przerywamy pętlę komendą break. W ten sposób nie popsujemy wysyłki, ale możemy pominąć część //zmian. Później wyjaśnię dlaczego tak to działa i czy ewentualnie można coś poprawić. if (strlen(msg) > (VW_MAX_MESSAGE_LEN - 7)) break; //sprawdzamy czy dany przycisk zmienił swój stan, jeżeli //tak to dopisujemy go do wiadomości if (ctrl.NewButtonState(ctrl.buttons_keys[i])) { strcat(msg, ctrl.buttons_values[i]); itoa(ctrl.Button(ctrl.buttons_keys[i]), value, 10); strcat(msg, value); } } //dodajemy oddzielenie na końcu strcat(msg, "|"); //i wysyłamy wiadomość send(msg); } |
TIP 3
W powyższym kodzie użyłem kilku dziwnych funkcji strcat, itoa i zamiast użyć “normalnych” Stringów, to korzystam z char. Po co to wszystko? Otóż użycie Stringów i proste dodawania ich znakiem +, lub nawet znalezienie funkcji formatującej jest bardzo wygodne, ale ma bardzo duży narzut. Po pierwsze jest dość wolne, ze względu na ciągłe użycie konstruktora, destruktora i przypisań pomiędzy. Dodatkowo może prowadzić do tzw. heap fragmentation i innych problemów z pamięcią. Generalnie Stringów najlepiej używać w środowiskach gdzie mamy dużo pamięci RAM i nie musimy się o nią martwić. Nie bardzo nadają się do użycia na mikrokontrolerach. Żeby tego było mało, to samo zadeklarowanie Stringa w kodzie, powoduje dołączenie do programu bibliotek, które zabierają sporo miejsca. Żeby nie być gołosłownym: zupełnie pusty projekt (tylko setup i loop) po skompilowaniu
Program size: 314 bytes
a po dodaniu jednej linijki String a = “a”;
Program size: 1 760 bytes
Teraz pozostało nam już tylko ustawienie kilku rzeczy w metodzie setup() a później cykliczne sprawdzanie co jest do wysłania w loop().
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
//ta metoda jest uruchamiana jeden raz po wystartowaniu programu void setup() { //konfiguracja komunikacji radiowej przez VirtualWire vw_set_tx_pin(transmit_pin); vw_setup(2000); //ustawienie pinu pod który podłączona jest dioda jako wyjście pinMode(led_pin, OUTPUT); //konfiguracja pada ps2Error = ctrl.config_gamepad(PS2_CLK, PS2_CMD, PS2_ATT, PS2_DAT, pressures, rumble); } //ta metoda jest główną pętlą programu, jest wykonywana cyklicznie raz za razem void loop() { //jeżeli pad nie został rozpoznany, to zapalamy diodę i kończymy działanie if (ps2Error != 0) { digitalWrite(led_pin, HIGH); delay(100); return; } //odczytujemy stan przycisków i drążków pada ctrl.read_gamepad(); //sprawdzenie czy zmienił się stan jakiegokolwiek przycisku lub drążka //jeżeli tak, to uruchamiamy funkcję która sprawdzi i wyśle nowe wartości if (ctrl.NewButtonState() || ctrl.StickValueChanged()) sendValues(); //sprawdzenie czy od wysłania ostatniej wiadomości minęło więcej //czasu niż założyliśmy (900ms). Jeśli tak to wysyłamy ping if (millis() - previousMillis >= interval) { sendPing(); } //zapisanie wartości położenia drążków, do porównania w następnym przebiegu ctrl.SaveSticksValues(); //dodanie drobnego opóźnienia delay(1); } |
I to by było na tyle jeżeli chodzi o kod nadajnika. Całość można pobrać z githuba (ostatni commit przed publikacją to 6266d5c, mogą pojawiać się następne z uaktualnieniami i poprawkami):
https://github.com/Cube82/atmega_controller
Teraz jeszcze na szybko kod odbiornika
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 |
//dodanie biblioteki do obsługi radia #include <VirtualWire.h> //definicja kanału, numer musi być zgodny z nadajnikiem #define channel 1 //oznaczenie nazwy dla elementu channel #define channelAlias "C" //moduł odbiornika podłączamy pon pin numer 2 #define receivePin 2 //w nadajniku wysyłamy sygnał najrzadziej co 900 milisekund //więc w odbiorniku zakładamy że jeżeli nie dostaniemy nic //przez 1000 to jesteśmy poza zasięgiem const long interval = 1000; //zmienna do przechowywania czasu ostatniego otrzymania wiadomości unsigned long previousMillis = 0; //klasa do obsługi otrzymanej wiadomości. Na razie nie ma imponującej //zawartości, ale w konkretnym rozwiązaniu będzie odpowiadała za //obróbkę wartości class Receiver { public: const char* PSS_RX = "rx"; const char* PSS_RY = "ry"; const char* PSS_LX = "lx"; const char* PSS_LY = "ly"; const char* PSB_SELECT = "se"; const char* PSB_L3 = "l3"; const char* PSB_R3 = "r3"; const char* PSB_START = "st"; const char* PSB_PAD_UP = "up"; const char* PSB_PAD_RIGHT = "rt"; const char* PSB_PAD_DOWN = "dn"; const char* PSB_PAD_LEFT = "lt"; const char* PSB_L2 = "l2"; const char* PSB_R2 = "r2"; const char* PSB_L1 = "l1"; const char* PSB_R1 = "r1"; const char* PSB_TRIANGLE = "tr"; const char* PSB_CIRCLE = "ci"; const char* PSB_CROSS = "cr"; const char* PSB_SQUARE = "sq"; }; //instancja naszej klasy odbiornika Receiver receiver; void setup() { //w przykładowym kodzie otrzymane wartości będziemy //wysyłać do portu szeregowego Serial.begin(9600); //ustawienia wymagane przez VirtualWire vw_set_rx_pin(receivePin); vw_setup(2000); //uruchamiamy nasłuchiwanie vw_rx_start(); } void loop() { //sprawdzenie czy coś przyszło checkMessage(); //obliczamy czy od ostatnio otrzymanej wiadomości //minęło więcej niż założony interwał (1 sekunda) if (millis() - previousMillis >= interval) { Serial.println("Signal lost!"); } } void checkMessage() { //tworzymy bufor na wiadomość, zwiększamy o 1 //maksymalną długość wiadomości, później wpiszemy //w to miejsce 0, które oznacza koniec danych uint8_t buf[VW_MAX_MESSAGE_LEN + 1]; uint8_t buflen = VW_MAX_MESSAGE_LEN; buf[0] = '\0'; if (vw_get_message(buf, &buflen)) { //otrzymaliśmy wiadomość, więc zapamiętujemy czas previousMillis = millis(); //dodajemy wspomniane 0 na końcu buf[VW_MAX_MESSAGE_LEN] = 0; //podobnie jak w kodzie nadajnika, również tutaj ominiemy //funkcje Arduino i skorzystamy z czystego, wydajnego C++ //strtok rozbija tekst na tokeny, dzieląc przy każdym "|" char* command = strtok((char *)buf, "|"); //w pętli odczytujemy kolejny zestaw danych while (command != 0) { //strchr znajduje pierwsze wystąpienie znaku : w tekście //* oznacza utworzenie wskaźnika do znalezionego elementu char* separator = strchr(command, ':'); if (separator != 0) { //przypisując 0 do wskaźnika zamieniamy znak : na 0 *separator = 0; //do zmiennej element podstawiamy nazwę przycisku/drążka char* element = command; //inkrementując wskaźnik sprawiamy że zaczyna wskazywać kolejny //element, w tym wypadku wartość która była zapisana za : ++separator; //konwertujemy wartość z char do int int value = atoi(separator); //teraz pozostaje kolejno sprawdzić jaki element nam przyszedł //i odpowiednio zareagować //strcmp porównuje dwa łańcuchy znaków, zwraca 0 jeśli są identyczne if (strcmp(element, channelAlias) == 0) { //sprawdzenie czy numery kanałów się zgadzają, //jeśli nie to wychodzimy z funkcji if (value != channel) return; } //powtarzalne sprawdzenie kolejnych możliwości, //można również to zrobić w pętli else if (strcmp(element, receiver.PSS_RX) == 0) { //przepisuję wartość do zmiennej, po to żeby podczas //debugowania wyświetlić ją w oknie Expressions int test_RX = value; //i wysyłam do portu szeregowego Serial.println("PSS_RX: " + String(value)); } else if (strcmp(element, receiver.PSS_RY) == 0) { int test_RY = value; Serial.println("PSS_RY: " + String(value)); } else if (strcmp(element, receiver.PSS_LX) == 0) { int test_LX = value; Serial.println("PSS_LX: " + String(value)); } else if (strcmp(element, receiver.PSS_LY) == 0) { int test_LY = value; Serial.println("PSS_LY: " + String(value)); } else if (strcmp(element, receiver.PSB_SELECT) == 0) { Serial.println("PSB_SELECT: " + String(value)); } else if (strcmp(element, receiver.PSB_L3) == 0) { Serial.println("PSB_L3: " + String(value)); } else if (strcmp(element, receiver.PSB_R3) == 0) { Serial.println("PSB_R3: " + String(value)); } else if (strcmp(element, receiver.PSB_START) == 0) { Serial.println("PSB_START: " + String(value)); } else if (strcmp(element, receiver.PSB_PAD_UP) == 0) { Serial.println("PSB_PAD_UP: " + String(value)); } else if (strcmp(element, receiver.PSB_PAD_RIGHT) == 0) { Serial.println("PSB_PAD_RIGHT: " + String(value)); } else if (strcmp(element, receiver.PSB_PAD_DOWN) == 0) { Serial.println("PSB_PAD_DOWN: " + String(value)); } else if (strcmp(element, receiver.PSB_PAD_LEFT) == 0) { Serial.println("PSB_PAD_LEFT: " + String(value)); } else if (strcmp(element, receiver.PSB_L2) == 0) { Serial.println("PSB_L2: " + String(value)); } else if (strcmp(element, receiver.PSB_R2) == 0) { Serial.println("PSB_R2: " + String(value)); } else if (strcmp(element, receiver.PSB_L1) == 0) { Serial.println("PSB_L1: " + String(value)); } else if (strcmp(element, receiver.PSB_R1) == 0) { Serial.println("PSB_R1: " + String(value)); } else if (strcmp(element, receiver.PSB_TRIANGLE) == 0) { Serial.println("PSB_TRIANGLE: " + String(value)); } else if (strcmp(element, receiver.PSB_CIRCLE) == 0) { Serial.println("PSB_CIRCLE: " + String(value)); } else if (strcmp(element, receiver.PSB_CROSS) == 0) { Serial.println("PSB_CROSS: " + String(value)); } else if (strcmp(element, receiver.PSB_SQUARE) == 0) { Serial.println("PSB_SQUARE: " + String(value)); } } //szukamy kolejnej pary nazwa:wartość command = strtok(0, "|"); } } } |
Kod jest dość uniwersalny, po prostu odbiera wiadomość, odczytuje poszczególne elementy i wyświetla je w oknie portu szeregowego. Utworzony jest zalążek klasy Receiver w której należy obsłużyć dalsze przetwarzanie danych w zależności od potrzeb.
Całość można pobrać z githuba (ostatni commit przed publikacją to 1e44fdf, mogą pojawiać się następne z uaktualnieniami i poprawkami):
https://github.com/Cube82/atmega_simple_receiver
TIP 4
Arduino IDE jest fajne, bo jest małe, darmowe i proste. Niestety ta prostota powoduje że większe projekty tworzy się z sporą uciążliwością. Każdy kto miał do czynienia z bardziej zaawansowanymi narzędziami wspomagającymi programowanie może z pewnym politowaniem patrzeć na mocno spartańskie środowisko pracy Arduino. Ale nie jesteśmy do niego ograniczeni. Ja osobiście używam Visual Micro – dodatek do Visual Studio (dostępne również w bezpłatnej wersji Community). Gorąco polecam, ponieważ daje nam nie tylko takie dodatki jak IntelliSense ale również debugowanie i całą masę innych ułatwień.
Kilka istotnych informacji o których trzeba wiedzieć (po resztę odsyłam do dokumentacji na stronie projektu):
– podczas instalacji Visual Studio należy zainstalować obsługę języka C++
– na komputerze na którym używamy Visual Micro musi być również zainstalowane Arduino IDE
– debugowanie jest dostępne w wersji Pro Visual Micro – licencja kosztuje od 29$, ale za darmo dostajemy 45 dni pełnej wersji i jeżeli nie chcemy płacić to spokojnie można używać wersji darmowej – nadal daje bardzo dużo ułatwień
– kod w wersji debug jest dużo większy od release – należy pamiętać aby ostateczną wersję kompilować w wersji release
Poniżej zrzut z Visual Studio na którym widać jak prezentują się wartości w oknie Output, Serial monitor, oraz najwygodniejszy w tym wypadku podgląd wyrażeń w oknie Expressions
Przygotowanie PCB
Ponieważ już dość mocno się rozpisałem, to z przedstawieniem przygotowania płytki PCB postaram się ograniczyć. Na początku zakładałem, że gotową płytkę schowam wewnątrz pada (są tam dwa silniczki, z których w tym przypadku nie będę korzystał i można je wyjąć), ale okazało się że bez korzystania z elementów SMD będzie to bardzo trudne. Tym bardziej, że założyłem możliwość łatwej aktualizacji oprogramowania w mikrokontrolerze i zamontowałem gniazdo ISP Kanda do podłączenia programatora (patrz np. tutaj).
Zacząłem od projektu układu w programie Eagle (kliknij aby powiększyć):
Później zaprojektowałem samą pytkę (kliknij aby powiększyć)
Jeżeli ktoś jest zainteresowany przeniesieniem projektu na PCB, ale nie chce sam bawić się z Eagle, to poniżej pliki które drukowałem do metody termotransferu (warstwę opisową należy odbić przed wydrukowaniem):
Tak wyglądała płytka przed wytrawianiem
A efekt końcowy wygląda tak:
Co można poprawić?
Powyżej opisałem pierwszą wersję kontrolera, jest jeszcze trochę rzeczy które można poprawić, usprawnić lub dodać. Pokrótce opiszę kilka pomysłów:
Kodowanie danych do wysłania
Wysyłane dane są zapisane w sposób ułatwiający zrozumienie co się w nich znajduje, np:
1 |
c:1|se:0|up:1|sq:1|ly:255| |
Z tym że długość wiadomości jest ograniczona do 30 znaków, a w powyższym przykładzie już jest użyte 26. Dlaczego tak to zostawiłem? Podczas testów zauważyłem, że zazwyczaj i tak się nie udaje wcisnąć kilku przycisków tak, żeby się razem wysyłały w jednym przebiegu pętli. Jeżeli jednak chcemy mieć pewność, że uda nam się przesłać wszystko za jednym razem, możemy użyć trochę magii – operatory bitowe. Mimo że jest to bardzo potężne, zwarte i szybkie narzędzie, to jest rzadko używane przez początkujących programistów. Dlaczego? Jak mówi stary dowcip jest 10 typów ludzi – ci którzy rozumieją system binarny i Ci którzy nie rozumieją. W naszym wypadku można zapisać do 8 wartości true/false w jednym bajcie! Nie rozpisując się w tym temacie za dużo, zaznaczę tylko że modyfikując założenia zapisu danych w wysyłanej wiadomości, moglibyśmy w niej zmieścić stan wszystkich przycisków, drążków i numer kanału. Dociekliwych odsyłam do analizy kodu biblioteki PS2X.
Parowanie urządzeń
Założyłem, że jednoczenie może pracować obok siebie więcej niż jedna para nadajnik-odbiornik (channel wysyłany na początku każdej wiadomości). Jest to jednak dość niewygodne, ponieważ numer kanału jest zapisany na stałe w programie. Żeby go zmienić trzeba przekompilować i wgrać nową wersję. Jak to poprawić? Sposobów może być wiele, np. możemy użyć zworek na płytce do wyboru kanału, lub dołączyć microswitch i zliczać ilość naciśnięć, lub użyć komunikacji dwukierunkowej i zaimplementować automatyczne parowanie.
Inny moduł radiowy
W tym projekcie użyłem najtańszych modułów radiowych jakie można znaleźć. Za nieco większą cenę możemy kupić bardziej zaawansowane produkty (np. nRF24L01), które mogą zapewnić nam większy zasięg, większą szybkość i niezawodność, oraz możliwość komunikacji dwukierunkowej i parowanie urządzeń.
Przejście na SMD
Zbudowany moduł nie jest duży, ale i tak nie chce się zmieścić do wnętrza pada. Gdyby przejść na SMD to płytka byłaby o wiele mniejsza i dało by się ją schować do środka. Na razie jednak nie czuję się na siłach do wykonania takiego projektu i zostanę przy montażu przewlekanym.
Wibracje
W tym projekcie nie bardzo było jak użyć wibracji dostępnych w padzie, ale przy wykorzystaniu komunikacji dwukierunkowej miałoby to już większy sens. Np. zdalnie sterowany samochód uderza o przeszkodę, odbiornik znajduje się poza zasięgiem, sterowane ramię wysięgnika osiągnęło maksymalne wychylenie itp.
Zasilanie
Kontroler jest zbudowany w oparciu o pad przewodowy, dlatego też nie jest wyposażony w żadne zasilanie. Sensownie byłoby włożyć do środka jakiś pakiet i umożliwić jego ładowanie. Ponieważ jednak ładowanie ogniw nie jest tematem prostym a może być dość niebezpieczne, to nie zdecydowałem się na to rozwiązanie w pierwszym projekcie. Póki co kontroler jest zasilany z powerbanku.
Rewelacja.
Bardzo profesjonalnie opisane. Miło że zwracasz uwagę na jakość kodu i przy okazji uczysz ludzi zarządzania pamięcią której w mcu jest mało.
Z ciekawości zapytam, w pracy też tak dogłębnie komentujesz kod czy to na rzecz poradnika?
Pozdrawiam
Dzięki. Im większy projekt tym bardziej trzeba się przykładać do kodu. Co do komentarzy – zdecydowanie nie ;-). Uważam że nie powinno się pisać tak rozbudowanych komentarzy, tutaj jest na potrzeby poradnika. Zresztą jeśli zajrzysz na github to zobaczysz że są minimalistyczne.
Ja się nauczyłem komentować swoje własne kody, gdy kiedyś po kilku miesiącach sam się nie mogłem połapać w programie, który wcześniej napisałem ;)
Mówi się, że kod który wymaga komentarzy jest źle napisany. Ale taką zasadę można stosować w językach i na platformach gdzie nie trzeba za wszelką cenę minimalizować, skracać i kombinować :)
Przy dużych i złożonych skryptach komentarze mimo wszystko są jednak fajne ;)
TEż się niegdyś a tym przejechałam. Sama się zastanawiałam co tak właściwie mój kod robi :D
@bejka: Ja właśnie miałem dokładnie to samo. Parę miesięcy wcześniej zacząłem pisać dosyć złożoną wtyczkę do WP, a później sam się zastanawiałem, jak ona działa ;)
Fajnie zostało to opisane, wydaje mi się że nie będę miał problemu z tym. Dzięki
Bardzo profesjonalny artykuł. W końcu dowiedziałem się co robię źle używając zmiennych string. Oby więcej takich artykułów.
Świetny artykuł ;)
Wreszcie jakiś ciekawy i _porządnie opisany_ projekt, a nie jakaś prowizorka o pająkach z taśm ledowych…
Otóż to. Oby jak najwięcej takich projektów! :)
Super projekt!!!!!
Czy jest szansa na stworzenie wersji na pada usb?
Hej,
Tak, da się podłączyć pod pada na USB, ale w takim wypadku układ musi się zachować jako host USB. Tutaj możesz poczytać więcej – https://www.arduino.cc/en/Main/ArduinoUSBHostShield oraz https://www.arduino.cc/en/Reference/USBHost
Świetnie napisany artykuł, zwłaszcza część teoretyczna i założenia do kodu. Co do minimalizacji i przejścia na SMD, nie myślałeś o zastosowaniu arduino mini pro, eliminuje to konieczność budowania filtrów do zasilania itp. a po dodaniu płytki drukowanej wszystko ładnie się trzyma łącznie ze złączem do programowania i nadajnikiem. Co do zasilania to najłatwiejszym sposobem jest gotowa ładowarka z microusb i jakaś bateria (np od Nokii) tylko pozostaje problem nadajnika przy napięciu poniżej 3,3V.