Sterowane cyfrowo diody RGB to elementy, które spotyka się w coraz większej liczbie urządzeń. Są one wygodne dla konstruktorów, bo wymagają tylko jednego pinu mikrokontrolera.
Komunikacja z diodami WS2812B wymaga jednak precyzji. Idealnie sprawdzą się tutaj liczniki, dzięki którym wygenerujemy odpowiednie sygnały.
Podczas wykonywania eksperymentów z tej części kursu dowiesz się, jak sterować diodami WS2812B. Nie będziemy jednak korzystać z gotowych rozwiązań – napiszemy własną, niskopoziomową bibliotekę, która będzie sterowała diodami za pomocą liczników sprzętowych. Oprócz tego omówimy w skrócie problematykę konwersji poziomów logicznych, a na koniec rozwikłamy zagadkę błędnego generowania sygnału PWM.
WS2812 – cyfrowe diody RGB
W trakcie omawiania PWM na STM32L4 pokazywaliśmy, jak sterować zwykłą diodą RGB. Element ten wymagał aż trzech wyprowadzeń mikrokontrolera STM32L4 i zaangażowania licznika sprzętowego (do generowania PWM). Nasz układ posiada mnóstwo timerów, ale ich liczba jest jednak ograniczona. Jeśli chcielibyśmy sterować 1–3 diodami RGB, to pewnie nie byłoby żadnego problemu. Gorzej, jeśli nasz projekt wymaga np. podłączenia 20 diod RGB – wtedy tradycyjne sterowanie jest już problematyczne.
Niektóre projekty wymagają sterowania setkami diod RGB – tradycyjne podejście byłoby tutaj niepraktyczne
Na szczęście z pomocą przychodzą nam diody RGB z wbudowanym sterownikiem (nazywane inaczej „cyfrowymi diodami RGB” lub „programowalnymi diodami RGB”). W zdecydowanej większości przypadków mowa o popularnych diodach WS2812B, które sprzedawane są w przeróżnej formie – od pojedynczych diod, przez różne moduły, aż po długie paski, zawierające dziesiątki diod na metrze.
Podczas dalszych ćwiczeń wykorzystamy moduł z 7 diodami RGB, które zostały wlutowane na gotowy, okrągły moduł. Od razu widać tutaj zaletę diod WS2812B – 7 diod RGB na jednej płytce i tylko trzy wyprowadzenia (zasilanie i jeden sygnał sterujący).
Pierścień z 7 diodami WS2812B
W dokumentacji samego modułu raczej nie znajdziemy niczego ciekawego. Zdecydowanie ważniejsza jest dla nas dokumentacja diod WS2812B. Zanim przejdziemy do analizy parametrów oraz tego, jak należy komunikować się z tymi diodami, warto zwrócić uwagę na opis wyprowadzeń.
Opis wyprowadzeń pojedynczej diody (fragment dokumentacji)
Każda z diod ma fizycznie cztery wyprowadzenia. Jednak po odliczeniu zasilania VDD i VSS zostają raptem dwa piny: DIN oraz DOUT. Pierwszy służy do komunikacji z konkretną diodą, a drugi pozwala na podłączenie kolejnej. Dzięki temu można łączyć diody w długie łańcuchy i sterować nimi za pomocą jednej linii danych. W związku z tym, że mamy jedną linię danych, a do zasilania potrzebne są dwa piny, dość łatwo domyślić się, jakie znaczenie mają piny dostępne w naszym module:
IN – linia danych
VCC – zasilanie
GND – masa
Zasilanie diod WS2812B
W dokumentacji znajdziemy informację, że zasilanie diod musi mieścić się w przedziale od 4,5 do 5,5 V. W naszym przypadku będzie więc to 5 V wprost z płytki Nucleo. Niestety dalej nie będzie już tak łatwo, bo napotkamy pewne problemy „napięciowe” na linii danych.
Informacja o poziomach logicznych na linii danych (fragment dokumentacji)
Powyższy zapis mówi nam o tym, jakie napięcie będzie traktowane przez diody (a właściwie przez ich sterownik) jako stan niski, a jakie jako stan wysoki. W tym przypadku za logiczne zero uznawane będzie napięcie w granicach od 0 V do 0,3 * VDD. W związku z tym, że moduł będzie zasilany z 5 V, górną granicą będzie 0,3 * 5 = 1,5 V.
Czyli napięcie na linii danych od 0 V do 1,5 V będzie traktowane jako stan niski. Ten parametr to dla nas nie problem, ale warto zauważyć, że nie zawsze stan niski to dokładnie 0 V.
Problemem jest jednak parametr informujący o tym, co będzie interpretowane jako wysoki stan logiczny. Tutaj jest to 0,7 * VDD, czyli 0,7 * 5 = 3,5 V – za logiczną jedynkę uznawane będzie zatem napięcie na linii danych od 3,5 V do 5 V. Nasz mikrokontroler, czyli STM32L476, zasilany jest z napięcia 3,3 V i właśnie takie napięcie pojawia się na jego wyprowadzeniach w stanie wysokim.
Metody konwersji poziomów logicznych
Mamy więc problem: nasza logiczna jedynka to 3,3 V, a sterownik w diodach będzie oczekiwał minimum 3,5 V. Problem ten jest powszechny (i dotyczy nie tylko tych diod), można go rozwiązać na wiele sposobów, opisywanych najczęściej jako metody konwersji poziomów logicznych. Oto przykłady kilku najpopularniejszych rozwiązań, które moglibyśmy tutaj wykorzystać.
Podniesienie napięcia zasilania
Na początku kursu wspominaliśmy, że nasz mikrokontroler może być zasilany napięciem od około 1,8 V do 3,6 V. Moglibyśmy więc podnieść napięcie zasilania i uzyskać wymagane 3,5 V na wyjściu. Takie rozwiązanie jest jednak trudne z co najmniej kilku powodów – napięcie 3,5 V jest dość nietypowe, więc zwyczajnie ciężko byłoby uzyskać stabilne 3,5 V. Wymagałoby to przeróbki płytki Nucleo.
Problemem jest też zmniejszenie marginesu dzielącego używane napięcie i dopuszczalne maksimum – lepiej mieć pewien zapas, niż korzystać z parametrów na granicy bezpieczeństwa.
Obniżenie napięcia zasilania
Można też obniżyć napięcie zasilania WS2812B (przynajmniej dla pierwszej diody w szeregu). Skoro mogą one pracować w zakresie od 4,5 V do 5,5 V, to zasilanie ich z 4,5 V obniżyłoby próg dla stanu wysokiego do 0,7 * 4,5 = 3,15 V. Tego typu rozwiązanie jest nieco prostsze w realizacji niż poprzednie – można spotkać układy, gdzie zwykła dioda prostownicza jest używana do obniżenia napięcia z 5 V do takiego w okolicy 4,5 V.
Niestety w miarę zwiększania liczby diod WS2812B ilość mocy traconej na diodzie prostowniczej wzrasta, więc to również nie jest idealne rozwiązanie.
Metoda na „u mnie działa”
Najczęstsza, prowizoryczna metoda, którą można spotkać w wielu poradnikach internetowych. Nikt się nie przejmuje błędnymi poziomami logicznymi, bo różnica między napięciem wyjściowym naszego mikrokontrolera a minimalnym oczekiwanym przez diody jest tak mała, że diody WS2812 działają po prostu sterowane z 3,3 V.
Prowizoryczne rozwiązania przy masowej produkcji mogą wymagać kosztownych akcji serwisowych
Problem w tym, że to nie najlepsze rozwiązanie, bo wiele może zależeć od konkretnych egzemplarzy diody. Dlatego u jednych osób to zadziała, u innych nie. Co więcej, takie rozwiązanie może psuć się w losowych momentach (np. zależnie od temperatury).
O ile metoda ta jest dopuszczalna podczas hobbystycznego majsterkowania, o tyle absolutnie nie powinno się korzystać z tego typu podejścia podczas seryjnej produkcji elektroniki.
Otwarty dren i rezystor podciągający
Podczas omawiania I2C na STM32L4 wspominaliśmy, że wyjścia mikrokontrolera mogą działać w trybie, w którym logiczne zero zwiera wyjście do masy, a logiczna jedynka zostawia pin niepodłączony. Jeśli dodamy do układu rezystor podciągający do 5 V, to będziemy mieli wówczas właśnie takie napięcie na wyjściu. STM32L476 nie może sam wymusić na wyjściu 5 V, ale większość pinów toleruje takie napięcie, więc to rozwiązanie jest jak najbardziej poprawne.
Wyjście z rezystorem podciągającym tworzy układ RC, dlatego zmiany stanu są szybkie, ale nie błyskawiczne, a to może powodować błędy w komunikacji.
Użycie konwertera napięć
Problem kompatybilności układów zasilanych różnymi napięciami nie jest niczym nowym, więc znajdziemy wiele gotowych rozwiązań pozwalających na łączenie takich układów ze sobą. Przykładowy układ scalony pozwalający na łączenie modułów pracujących z napięciami 3,3 V oraz 5 V to TXB0104. Zapewnia on odpowiednie czasy narastania, wymagane napięcia itd.
Rozwiązanie to ma właściwie tylko jedną wadę – cenę. Każdy element to dodatkowy koszt, który może być problemem podczas produkcji seryjnej, a ten element jest stosunkowo drogi.
Użycie bufora cyfrowego
Przygotowując zestawy elementów do kursu STM32L4, zdecydowaliśmy się na użycie jeszcze innego rozwiązania. Komunikacja z diodami przebiega jednokierunkowo (wysyłamy dane z mikrokontrolera do sterowników diod), nie potrzebujemy więc dwukierunkowego konwertera napięć.
W takim celu zamiast drogiego konwertera można wykorzystać jeden ze stosunkowo tanich układów serii 74HCT. Są to układy logiczne, które mogą być zasilane z 5 V, ale – co ciekawe – napięcie na ich wejściach zaczynające się od 2 V traktują już jako stan wysoki. W naszym przypadku zdecydowaliśmy się na wykorzystanie układu 74HCT125, który jest wyposażony w cztery bufory cyfrowe.
Wyprowadzenia 74HCT125 (fragment dokumentacji)
Bufor działa w taki sposób, że po ustawieniu na zanegowanych liniach OE stanu niskiego przekazuje on stan wejścia na wyjście. W naszym układzie połączymy wejście OE z masą na stałe i wówczas na wyjściu będziemy mieli taki sam stan jak na wejściu. Logicznie bufor nie robi właściwie nic ciekawego, ale jeśli popatrzymy na jego parametry elektryczne, znajdziemy tam to, co nas interesuje.
Interpretacja napięć przez układ 74HCT125
Jak widać, parametr VIH nie zależy od napięcia zasilania i wynosi 2 V, czyli wartość, jaką bez problemu nasz STM32L476 jest w stanie zapewnić. Natomiast na wyjściu układu 74HCT125 pojawią się sygnały zgodne z napięciem zasilania, czyli w tym przypadku będzie to 5 V. Co więcej, wyjścia tego bufora pracują w trybie push-pull, więc czasy narastania nie stanowią problemu.
Użycie układów serii 74HCT jest często spotykaną sztuczką. Warto zapamiętać, że może to być znacznie tańsze rozwiązanie niż pełnoprawny konwerter napięć.
Podłączenie diod WS2812B do STM32L4
Za nami krótki wstęp teoretyczny na temat „napięć”, więc możemy już spokojnie przejść do budowy układu. Od strony mikrokontrolera wykorzystamy tylko jeden pin – PA6. Zarówno bufor 74HCT125, jak i moduł z diodami WS2812B są zasilane z 5 V; warto pamiętać o podłączeniu kondensatorów 100 nF oraz 220 µF. Dzięki temu unikniemy problemów, które mogłyby się pojawić przy większym poborze prądu (generowanym np. podczas szybkiego migania wszystkimi diodami).
Schemat ideowy i montażowy dla przykładów z tej części kursu STM32L4
Komunikacja z diodami WS2812B
Protokół komunikacji z diodami WS2812B jest prosty, ale nie korzystają one z żadnego interfejsu, który już znamy. Kolor każdej diody zapisany jest jako 24-bitowa wartość (po 8 bitów na każdą składową koloru RGB). Kolejność przesyłania jest nietypowa, bo najpierw przesyłana jest składowa zielona następnie czerwona, a na końcu niebieska. Wszystkie w kolejności od najwyższego bitu do najniższego:
Sposób formatowania informacji na temat kolorów dla diod WS2812B
Jeśli więcej diod jest połączonych szeregowo (czyli prawie zawsze), to najpierw przesyłane są dane dla pierwszej diody, następnie drugiej, trzeciej itd. Na najniższym poziomie przesyłane są trzy rodzaje informacji: RESET, bit reprezentujący logiczne zero i bit reprezentujący logiczną jedynkę.
Informacja na temat sposobu transmisji bitów (fragmenty dokumentacji)
Jak widać, poza symbolem resetu, który trwa powyżej 50 µs, czasy przesyłania bitów są dość krótkie – jeden bit „trwa” 1,25 µs, co nawet dla mikrokontrolera pracującego z częstotliwością 80 MHz jest krótkim czasem (transmisja jednego bitu trwa dokładnie 100 cykli zegara naszego mikrokontrolera).
Wśród modułów peryferyjnych STM32L476 nie znajdziemy układu obsługującego protokół WS2812B (i nie znajdziemy go raczej w żadnych innych mikrokontrolerach). Teoretycznie można byłoby próbować napisać program, który sterowałby GPIO zgodnie z wymaganymi czasami, ale byłoby to bardzo trudne i wymagało wyłączenia obsługi przerwań.
Tolerancja protokołu WS2812B jest na poziomie 150 ns, więc pojawienie się przerwania powodowałoby już błędy w komunikacji.
Można jednak wykorzystać dostępne moduły sprzętowe do komunikacji z diodami WS2812B – istnieją przykłady wykorzystania modułów SPI oraz UART (chociaż, jak wiemy, domyślnie są używane w zupełnie innych celach). Podczas tworzenia tego kursu zdecydowaliśmy się jednak na opisanie metody z użyciem liczników – schemat przesyłania bitów bardzo przypomina przebieg PWM, będzie to zatem okazja do poznania dodatkowych możliwości tych modułów.
Konfiguracja projektu
Możemy nareszcie przejść do programowania. Zaczynamy od projektu z STM32L476RG, który pracuje z częstotliwością 80 MHz (koniecznie), standardowo uruchamiamy też debugger. Zaznaczamy również w opcjach projektu, że CubeMX ma wygenerować osobne pliki dla wszystkich modułów.
Od razu idziemy też dalej i przechodzimy do konfiguracji modułu TIM3:
Clock Source: Internal Clock
Channel 1: PWM Generation CH1
Counter Period: 99
Dla formalności: ustawienie licznika na 99 oznacza podział częstotliwości 80 MHz przez 100, co daje 800 kHz, czyli 1,25 µs, co odpowiada czasowi transmisji jednego bitu dla diod WS2812B.
Konfiguracja licznika TIM3 do obsługi diod WS2812B
Tryb PWM, który poznaliśmy wcześniej, ustawiał stałe wypełnienie sygnału, teraz chcielibyśmy jednak zmieniać wypełnienie dla każdego cyklu, odpowiednio do przesyłanej informacji. Posłuży nam do tego mechanizm DMA, który będzie w locie aktualizował ustawienia PWM.
W dolnej części okna konfiguracji TIM3 przechodzimy więc do zakładki DMA Settings i klikamy przycisk Add. Następnie wybieramy kanał TIM3_CH1/TRIG oraz ustawienia:
Direction: Memory To Peripheral
Data Width:
Peripheral: Half Word
Memory: Byte
Konfiguracja DMA dla licznika TIM3
Wybraliśmy kierunek przesyłania danych z pamięci do modułu peryferyjnego, ponieważ będziemy wysyłać dane. Ciekawsze jest ustawienie szerokości danych. Większość liczników jest 16-bitowych, zatem licznik naszego TIM3 oczekuje właśnie takiego formatu danych i dlatego po stronie Peripheral wybraliśmy wielkość danych Half Word. Okres timera jest równy 100, więc przechowywanie w pamięci aż 16-bitowych wartości nie jest konieczne, wystarczy nam 8 bitów – ustawiamy po stronie Memory rozmiar danych na Byte, a mechanizm DMA uzupełni wyższe bity zerami.
Dzięki temu DMA sprawi, że zmienne 8-bitowe trafią do rejestru sprzętowego w zapisie 16-bitowym. Gdyby nie to, musielibyśmy zadeklarować tablicę wartości jako uint16_t (co zajęłoby dwa razy więcej pamięci RAM od aktualnej tablicy uint8_t).
Pierwszy program obsługujący diody WS2812B
Możemy przejść już do pisania właściwego kodu. Sterowanie diodami WS2812 jest zaskakująco łatwe, ale najpierw musimy nieco lepiej poznać działanie trybu PWM w połączeniu z DMA. Przyda nam się do tego analizator stanów logicznych – oczywiście zamieściliśmy tutaj zrzuty z takiego narzędzia.
Najpierw uruchamiamy moduł timera, wywołując funkcję HAL_TIM_Base_Start. Następnie tworzymy tablicę z przykładowymi danymi – jak pamiętamy, okres sygnału PWM wynosi 100, więc podane liczby można interpretować jako procent wypełnienia. Na koniec wywołujemy funkcję HAL_TIM_Start_DMA. Jej nagłówek wygląda następująco:
Pierwszy parametr to wskaźnik do naszego licznika, czyli zmiennej htim3, drugim jest numer kanału – używamy kanału 1, więc przekazujemy stałą TIM_CHANNEL_1. Następnie przekazujemy wskaźnik do bufora z danymi oraz liczbę danych do wysłania. Ustawiliśmy wcześniej rozmiar danych w pamięci na Byte, dlatego nasza tablica ma elementy typu uint8_t.
Efekt działania powyższego programu (podgląd z analizatora stanów logicznych)
Jak widać po lewej stronie, domyślnie pin był wysterowany stanem niskim. Wartość 10 spowodowała wygenerowanie impulsu o wypełnienie 10%, kolejna wartość to 20, czyli 20% wypełnienia itd. Tym, co może być zaskoczeniem, jest natomiast powtarzanie ostatniej wartości.
Wolelibyśmy, żeby po przesłaniu danych stan linii był ustawiony jako wysoki, dlatego możemy na końcu dodać wartość 100, czyli pełne wypełnienie. Dodajmy również 100 na początku, aby lepiej było widać, gdzie zaczyna się nasz sygnał.
Efekt działania drugiej wersji programu (podgląd z analizatora stanów logicznych)
Okres naszego sygnału to 1,25 µs. Aby przesłać bit oznaczający 0, musimy wygenerować impuls o szerokości około 400 ns. Jeden cykl zegara ma 12,5 ns, więc 32 cykle powinny dać oczekiwany efekt:
W efekcie uzyskamy przebieg zbliżony do poniższego – wszystko działa więc poprawnie:
Efekt działania trzeciej wersji programu (podgląd z analizatora stanów logicznych)
Podobnie możemy policzyć i przetestować, że aby wysłać bit oznaczający 1, potrzebujemy impulsu trwającego 800 ns, co odpowiada wartości 64.
Na koniec zostaje nam wygenerowanie sygnału RESET. Z dokumentacji dowiemy się, że jest to po prostu stan niski trwający ponad 50 µs. Jeśli ustawimy wypełnienie o wartości 0, to na wyjściu pojawi się stan niski przez 1,25 µs. Wystarczy więc, że wyślemy 40 razy wartość 0, i osiągniemy oczekiwany rezultat (w rzeczywistości lepiej użyć nieco dłuższego czasu).
Wiemy już wystarczająco dużo o działaniu PWM, możemy zostawić analizator stanów logicznych i wrócić do sterowania diodami. Stwórzmy pierwszy, jeszcze bardzo brzydki, ale prosty w zrozumieniu program. Na początek deklarujemy tablicę wypełnioną zerami:
uint8_t test[40 + 7 * 24 + 1] = {0};
Wielkość tego buforu wynika z tego, że potrzebujemy miejsca na następujące elementy:
40 cykli PWM zerowych, aby wysłać sygnał RESET,
następnie dane dla 7 diod WS2812B (każda oczekuje 24 bitów koloru),
1 cykl z wypełnieniem 100%, który będzie powtarzany po zakończeniu transmisji.
Tablica jest wypełniona zerami, ale diody powinny otrzymać impuls o szerokości 400 ns, aby odczytać te dane jako 0. Wpisujemy zatem w odpowiednie miejsce tablicy wartość 32, bo takie wypełnienie da właśnie impulsy o szerokości 400 ns.
for (int i = 0; i < 7 * 24; i++)
test[40 + i] = 32;
Pozostało nam jeszcze dodać na końcu wypełnienie równe 100% i wysłać dane za pomocą poniższej linijki – kompilator zgłosi później do niej ostrzeżenie na temat typu danych (podczas testów może je jednak zignorować, można też dodać tutaj jawne rzutowanie na uint32_t):
Jeśli uruchomimy taki program, to nic się nie wydarzy – ewentualnie diody zgasną (jeśli z jakiegoś powodu wcześniej się świeciły). Dodajmy więc drobną zmianę, mianowicie bit reprezentujący 1 dla pierwszej diody (czyli musimy ustawić PWM o wypełnieniu 64):
test[40] = 64;
Cały kod powinien wyglądać teraz następująco:
/* USER CODE BEGIN 2 */
HAL_TIM_Base_Start(&htim3);
uint8_t test[40 + 7 * 24 + 1] = {0};
// Zerowanie kolorów wszystkich diod
for (int i = 0; i < 7 * 24; i++)
test[40 + i] = 32;
// Włącz jedną diodę
test[40] = 64;
// Stan wysoki na końcu
test[40 + 7 * 24] = 100;
HAL_TIM_PWM_Start_DMA(&htim3, TIM_CHANNEL_1, &test, sizeof(test));
/* USER CODE END 2 */
Po uruchomieniu powinniśmy zobaczyć, że jedna dioda została włączona na kolor zielony. Wiemy już, jak można komunikować się z diodami WS2812B. Czas napisać o wiele czytelniejszą wersję programu.
Efekt działania powyższego programu – pierwsza dioda w szeregu świeci na zielono
Optymalizacja kodu do obsługi WS2812B
Tym razem przeniesiemy całość od razu do osobnej biblioteki. Zaczynamy od pliku nagłówkowego ws2812b.h, w którym umieszczamy informacje na temat czterech funkcji:
Definicje BIT_0_TIME i BIT_1_TIME określają liczbę cykli zegara dla poszczególnych stanów, aby były one poprawnie rozpoznawane przez sterowniki wbudowane do naszych diod. Z kolei RESET_LEN to liczba cykli potrzebnych, aby diody wykryły tzw. stan RESET. Na koniec LED_N definiuje liczbę diod, których będziemy używać.
Teraz, wewnątrz pliku ws2812b.c, możemy zdefiniować bufor na dane oraz funkcję inicjalizującą:
static uint8_t led_buffer[RESET_LEN + 24 * LED_N + 1];
void ws2812b_init(void)
{
int i;
for (i = 0; i < RESET_LEN; i++)
led_buffer[i] = 0;
for (i = 0; i < 24 * LED_N; i++)
led_buffer[RESET_LEN + i] = BIT_0_TIME;
led_buffer[RESET_LEN + 24 * LED_N] = 100;
HAL_TIM_Base_Start(&htim3);
ws2812b_update();
}
Kod jest właściwie identyczny z tym, czego używaliśmy wcześniej, jednak użycie stałych zamiast „magicznych liczb” trochę poprawia czytelność. Na końcu dodaliśmy również wywołanie nowej funkcji, czyli ws2812b_update, od razu dodajemy więc odpowiednią definicję – zadaniem tej funkcji jest „wysłanie nowych danych” przez PWM za pomocą DMA.
Przesyłanie danych za pomocą DMA odbywa się w tle, ale gdybyśmy jednak z jakiegoś powodu chcieli poczekać na zakończenie transmisji, możemy od razu dodać do biblioteki funkcję ws2812b_wait:
void ws2812b_wait(void)
{
while (HAL_TIM_GetChannelState(&htim3, TIM_CHANNEL_1) == HAL_TIM_CHANNEL_STATE_BUSY)
{}
}
Teraz napiszmy funkcję pomocniczą, która zakoduje 8-bitową liczbę w postaci 8 bajtów, tak aby po wysłaniu ich do timera otrzymać sterowanie zgodne z oczekiwaniami diod WS2812B. Jest to funkcja statyczna, więc powinna znaleźć się w pliku ws2812b.c, nad wcześniej dodanymi funkcjami.
static void set_byte(uint32_t pos, uint8_t value)
{
int i;
for (i = 0; i < 8; i++) {
if (value & 0x80) {
led_buffer[pos + i] = BIT_1_TIME;
} else {
led_buffer[pos + i] = BIT_0_TIME;
}
value <<= 1;
}
}
Bity wysyłane są w kolejności od najbardziej znaczącego – sprawdzamy wartość bitu przez porównanie z wartością 0x80. Jeśli najwyższy bit jest ustawiony na 1, to wstawiamy do tablicy wartość BIT_1_TIME, a w przeciwnym przypadku BIT_0_TIME.
Przesunięcie bitowe w lewo (na końcu) sprawia, że kolejny bit jest przesuwany na pozycję najwyższą i w kolejnym obiegu pętli będzie przetworzony tym samym kodem.
Teraz to, co najważniejsze, czyli funkcja ws2818b_set_color, dzięki której ustawimy kolor diod:
Jak pamiętamy, na początku mamy RESET_LEN bajtów zajętych przez wysyłanie stanu RESET – każda dioda zajmuje 24 bajty, a kolory są ustawiane w kolejności GRB. Oprócz tego za pomocą prostego warunku sprawdzamy tylko dla bezpieczeństwa, czy nie próbujemy ustawić koloru dla „diody”, która wykracza poza miejsce zarezerwowane w buforze.
Teraz biblioteka jest gotowa, możemy wrócić do programu głównego i przetestować, czy całość działa tak, jak chcieliśmy. Zaczynamy oczywiście od dodania plików nagłówkowych:
#include <stdlib.h>
#include "ws2812b.h"
Teraz możemy napisać program główny:
/* USER CODE BEGIN WHILE */
ws2812b_init();
while (1)
{
uint8_t r = rand();
uint8_t g = rand();
uint8_t b = rand();
for (int led = 0; led < 6; led++) {
ws2812b_set_color(led, r, g, b);
ws2812b_update();
HAL_Delay(100);
}
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
}
/* USER CODE END 3 */
Losujemy wartości dla trzech zmiennych 8-bitowych, które wykorzystujemy później jako losowe kolory. W pętli uruchamiamy kolejne diody na wybrany kolor i wywołujemy funkcję ws2812b_update, aby wyświetlić nowy wzór itd. Po uruchomieniu kodu powinniśmy zobaczyć efekt zbliżony do poniższego.
Przykładowy efekt wygenerowany przez najnowsza wersję programu
Korekcja gamma
Efekt poprzedniego programu, chociaż ładny, ma jednak pewną wadę. Diody świecą bardzo jasno i większość losowych kolorów jest bardzo zbliżona do białego. Okazuje się, że diody WS2812B mają tę samą wadę co zwykłe diody RGB – ich jasność nie zależy liniowo od poziomu wypełnienia.
Moglibyśmy użyć matematycznego wzoru z części 8 kursu, ale lepiej sprawdzi się rozwiązanie, które na swoich stronach opisuje firma Adafruit. Umieszczony tam kod dotyczy co prawda Arduino, ale możemy skopiować sobie tablicę z tzw. korekcją gamma i wstawić ją dla testu do pliku main:
Teraz możemy zmodyfikować nasze demo, tak aby kolory były poddawane odpowiedniej korekcie. Wystarczy, że zamiast wylosowanej wartości jako kolor ustawimy odpowiednią wartość z tablicy. Trzeba więc (oprócz dodania tablicy ze współczynnikami) podmienić tylko trzy linijki na poniższe (dzięki dzieleniu modulo 256 otrzymujemy liczbę z zakresu od 0 do 255):
uint8_t r = gamma8[rand() % 256];
uint8_t g = gamma8[rand() % 256];
uint8_t b = gamma8[rand() % 256];
Teraz kolory powinny być o wiele ładniejsze niż w pierwszej wersji programu. Nie jest to kluczowe dla tematu tej części kursu, ale po prostu warto pamiętać o korekcie gamma podczas wykorzystywania tego typu diod, bo wyniki będą wtedy znacznie bardziej atrakcyjne.
Dla zainteresowanych używaniem PWM oraz DMA
Nasze sterowanie diodami WS2812B działa bardzo ładnie i właściwie moglibyśmy na tym poprzestać. Jednak spostrzegawczy czytelnicy mogą zauważyć nieco zbyt szerokie impulsy na diagramach z wypełnieniem 100%. Nie musieliśmy się tym zbytnio przejmować, bo nasze sterowanie nie przekraczało 64%, ale warto byłoby zobaczyć, skąd tak dziwne zachowanie modułu oraz jak poprawić jego działanie (dla spokoju własnego sumienia).
Nie chcemy zamiatać niczego pod dywan i udawać, że tak miało być. Zaraz to poprawimy, ale w tym konkretnym przypadku nie przełoży się to na żaden wizualny efekt – dlatego podeprzemy się tutaj zrzutami z analizatora stanów logicznych, aby każdy mógł dostrzec problem.
Zacznijmy od prostego testu. Wygenerujemy trzy „cykle” PWM z wypełnieniem kolejno 90% oraz 0%. Wracamy więc do pierwszej wersji kodu i testujemy następujący fragment:
/* USER CODE BEGIN 2 */
HAL_TIM_Base_Start(&htim3);
uint8_t test[] = { 90, 0, 90, 0, 90, 0 };
HAL_TIM_PWM_Start_DMA(&htim3, TIM_CHANNEL_1, &test, sizeof(test));
/* USER CODE END 2 */
Po uruchomieniu możemy sprawdzić, że wszystko działa dokładnie tak, jak tego oczekujemy:
Podgląd wygenerowanych sygnałów za pomocą analizatora stanów logicznych
Spróbujmy więc zmienić wypełnienie z 90% na 100% – wystarczy podmiana tablicy:
uint8_t test[] = { 100, 0, 100, 0, 100, 0 };
Zmieniliśmy tylko wypełnienie, ale program zachowuje się inaczej, niż tego oczekujemy – wypełnienie wynosi co prawda 100%, ale szerokość impulsu wzrosła i odpowiada dwóm cyklom (2 × 1,25 µs).
Zaskakujące działanie programu – wzrost szerokości impulsu
Co ciekawe, jeszcze gorzej będzie, jeśli użyjemy wartości pośredniej, przykładowo 94%:
uint8_t test[] = { 94, 0, 94, 0, 94, 0 };
Teraz nasz przebieg wygląda zupełnie inaczej, niż byśmy chcieli:
Jeszcze dziwniejszy efekt działania pozornie prostego programu
Aby zrozumieć przyczynę takiego zachowania naszego PWM, musimy nieco dokładniej zagłębić się w działanie układu czasowo-licznikowego. W części 8 kursu wspominaliśmy, że sercem timera jest rejestr nazwany CNT (TIM counter register), czyli 16-bitowy rejestr, który jest zwiększany w każdym cyklu zegara. Jego wartość jest porównywana z kolejnym rejestrem, tym razem o nazwie ARR (Auto-reload register), którego wartość ustawiliśmy na 99 podczas konfiguracji za pomocą CubeMX.
Fragment ustawień z CubeMX
Wartość CNT zmienia się od 0 do 99, dlatego okres naszego timera wynosi 100. Po upływie tego czasu generowane jest zdarzenie o nazwie UEV (Update Event), wartość rejestru CNT jest zerowana, a to już rozpoczyna kolejny okres PWM, na wyjściu jest więc ustawiany stan wysoki.
Każdy kanał posiada własny rejestr pamiętająco-porównujący, a w związku z tym, że używamy kanału numer 1, interesujący nas rejestr to CCR1 (TIM capture/compare register 1). Kiedy wartość rejestru CNT jest równa CCR1, generowane jest zdarzenie CCEV (Capture/Compare Event), a stan na wyjściu zmieniany jest na niski.
Dlatego wartość rejestru CCR1 określa wypełnienia naszego PWM.
Wartość rejestru CCR1 jest buforowana w tzw. rejestrze cieni (shadow register). Czyli zapis do rejestru CCR1 nie daje efektu natychmiast, ale dopiero po zakończeniu cyklu PWM. Buforowanie to bardzo przydatny mechanizm, więc zostawiliśmy je włączone podczas konfiguracji timera.
Aktywacja buforowania w CubeMX
Teraz wiemy już właściwie wszystko, co jest potrzebne do zrozumienia przyczyny naszych problemów. Mechanizm DMA działa w ten sposób, że po wystąpieniu zdarzenia CCEV zapisuje nową wartość z pamięci RAM, czyli naszej tablicy test do rejestru CCR1.
Niestety, nawet zapis za pomocą DMA nie następuje natychmiast, lecz wymaga nieco czasu. Jeśli wypełnienie jest bliskie 100%, to zdarzenie CCEV wystąpi za późno i kolejny cykl PWM rozpocznie się z użyciem poprzedniej wartości CCR1!
Dlatego kod działa dobrze, o ile wypełnienie nie przekracza 90% – później zaczynają się problemy.
Znacznie lepszym rozwiązaniem byłoby kopiowanie danych w odpowiedzi na zdarzenie UEV zamiast CCEV, jednak funkcja HAL_TIM_PWM_Start_DMA tego nie umożliwia. Aby używać zdarzenia UEV, wracamy do CubeMX, przechodzimy do konfiguracji TIM3 i wybieramy zakładkę DMA Settings.
Jeśli mamy skonfigurowany kanał TIM3_CH1/TRIG, kasujemy go przyciskiem Delete, a następnie dodajemy nowy, o nazwie TIM3_CH4/UP. Parametry kanału ustawiamy podobnie jak poprzednio, czyli kierunek na Memory To Peripheral, a jako wielkość danych w pamięci ustawiamy Byte.
Nowa konfiguracja dla DMA w CubeMX
Teraz możemy zapisać zmiany i wrócić do edycji kodu – podobnie jak poprzednio najpierw musimy uruchomić działanie licznika bazowego:
HAL_TIM_Base_Start(&htim3);
Następnie zamiast funkcji HAL_TIM_PWM_Start_DMA, która – jak wiemy – nie spełnia naszych oczekiwań, wywołamy HAL_TIM_PWM_Start, której już używaliśmy wcześniej (gdy nie korzystaliśmy z DMA):
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);
Teraz najtrudniejsza część, czyli wywołanie funkcji HAL_TIM_DMABurst_MultiWriteStart, dzięki której wykonamy transfer danych przy użyciu DMA:
zdarzenie, które będzie wyzwalać zapis (UPDATE / UEV),
bufor danych do wysłania,
ilość danych przesyłanych w jednym transferze DMA,
wielkość bufora.
Teraz nasz program będzie działał dokładnie tak, jak oczekujemy, niezależnie od wypełnienia sygnału:
Efekt działania poprawionej wersji programu
Użycie funkcji HAL_TIM_DMABurst_MultiWriteStart jest nieco trudniejsze niż HAL_TIM_PWM_Start, ale może być niezbędne, jeśli chcielibyśmy używać wypełnienia bliskiego 100%. W przypadku diod RGB na szczęście wystarczy łatwiejsza opcja – temat ten opisaliśmy jednak w ramach ciekawostki.
Quiz – sprawdź, ile już wiesz!
Przygotowaliśmy aż cztery quizy, dzięki którym sprawdzisz, jak dużo zapamiętałeś z tego kursu. Masz za sobą już piętnaście części kursu, więc możesz zabrać się za kolejny quiz – składa się on z 15 pytań testowych (jednokrotnego wyboru), a limit czasu to 15 min. W rankingu liczy się pierwszy wynik, ale w quizie będziesz mógł później wziąć udział wielokrotnie (w ramach treningu).
Bez stresu! Postaraj się odpowiedzieć na pytania zgodnie z tym, co wiesz, a w przypadku ewentualnych problemów skorzystaj ze swoich notatek. To nie są wyścigi – ten quiz ma pomóc w utrwaleniu zdobytej już wiedzy i wyłapaniu tematów, które warto jeszcze powtórzyć. Powodzenia!
Quiz - najnowsze wyniki
Oto wyniki 10 osób, które niedawno wzięły udział w quizie. Teraz pora na Ciebie! Uwaga: wpisy w tej tabeli mogą pojawiać się z opóźnieniem, pełne wyniki są dostępne „na żywo” na stronie tego quizu.
Stwórz lampkę RGB, którą będzie można obsługiwać za pomocą przycisku USER_BUTTON (na płytce Nucleo). Każde naciśnięcie przycisku powinno powodować przejście do następnego stanu lampki zgodnie ze schematem: wszystkie czerwone > efekt 1 > wszystkie zielone > efekt 2 > wszystkie niebieskie > efekt 3 > diody wyłączone, a w miejscu stanów opisanych jako efekty diody powinny migać lub zmieniać kolory zgodnie z napisanymi przez Ciebie funkcjami.
Wykorzystaj moduł diod RGB do wizualizowania odczytów z czujnika ultradźwiękowego. Gdy przeszkoda jest dalej niż 100 cm od czujnika, wszystkie diody powinny świecić się na zielono. W zakresie od 99 do 50 cm kolejne diody (zgodnie z kierunkiem wskazówek zegara) powinny zmieniać swój kolor na żółty. Analogicznie w zakresie od 49 do 10 cm diody powinny zmieniać kolor na czerwony, a przy odległości poniżej 10 cm powinny migać na czerwono.
Podsumowanie – co warto zapamiętać?
Cyfrowe diody RGB to niezwykle użyteczne elementy, dzięki którym można zaoszczędzić wiele GPIO. Ich obsługa jest stosunkowo łatwa – trzeba tylko wiedzieć, jak podejść do tego tematu. Czasami nie warto korzystać z gotowych programów, które operują bezpośrednio na GPIO, bo znacznie lepsze efekty można uzyskać za pomocą sprzętowych peryferii.
Czy wpis był pomocny? Oceń go:
Średnia ocena 4.9 / 5. Głosów łącznie: 35
Nikt jeszcze nie głosował, bądź pierwszy!
Artykuł nie był pomocny? Jak możemy go poprawić? Wpisz swoje sugestie poniżej. Jeśli masz pytanie to zadaj je w komentarzu - ten formularz jest anonimowy, nie będziemy mogli Ci odpowiedzieć!
Do cyfrowych diod RGB jeszcze wrócimy, i to w kolejnej części, w której zajmiemy się komunikacją bezprzewodową za pomocą IR. Dzięki temu będziemy mogli zbudować zdalnie sterowaną lampkę RGB. Oczywiście przy okazji poznamy również kolejne zastosowanie liczników – wykorzystamy je właśnie do dekodowania sygnałów z odbiornika IR.
Główny autor kursu: Piotr Bugalski Współautor: Damian Szymański, ilustracje: Piotr Adamczyk Oficjalnym partnerem tego kursu jest firma STMicroelectronics Zakaz kopiowania treści kursów oraz grafik bez zgody FORBOT.pl
Dołącz do 30 tysięcy osób, które otrzymują powiadomienia o nowych artykułach! Zapisz się, a otrzymasz PDF-y ze ściągami (m.in. na temat mocy, tranzystorów, diod i schematów) oraz listę inspirujących DIY na bazie Arduino i Raspberry Pi.
Dołącz do 30 tysięcy osób, które otrzymują powiadomienia o nowych artykułach! Zapisz się, a otrzymasz PDF-y ze ściągami (m.in. na temat mocy, tranzystorów, diod i schematów) oraz listę inspirujących DIY z Arduino i RPi.
Trwa ładowanie komentarzy...