Płynna zmiana koloru diody RGB w Arduino

Często podłączając diodę RGB do Arduino chcemy wysterować ją tak, aby świeciła nie tylko w jednym z kilku podstawowych kolorów, ale w jednym z ponad 16 milionów. W niektórych przypadkach dobrze by było także, gdyby kolory zmieniały się płynnie (np. w sterownikach oświetlenia). Dziś napiszemy prostą klasę, która zapewni nam wysokopoziomowe metody do sterowania kolorem takiej diody.

Zasada działania

Pierwsze co będziemy musieli zrobić, aby płynnie wysterować diodę jest napisanie funkcji, która jako parametry wejściowe przyjmie kolor wyrażony za pomocą palety HSV (Hue, Saturation, Value). Funkcja ta przeliczy nam podane wartości na poszczególne składowe w palecie RGB. Dlaczego chcemy sterować za pomocą HSV a nie RGB, którego nie musielibyśmy przecież przeliczać? HSV jest bardziej intuicyjny dla człowieka – możemy ustalić wybrany kolor, nasycenie oraz jasność. Posługując się paletą RGB musielibyśmy bawić się w mieszanie składowych czerwonej, zielonej i niebieskiej.

Ok, potrafimy już obliczyć wartości RGB odpowiadające obu kolorom, które chcemy wyświetlić. Teraz wystarczy ustalić ile czasu ma trwać animacja oraz na podstawie wyników funkcji millis() obliczyć ile procent przejścia mamy już za sobą. Pozostało nam już tylko ustalić pośrednie wartości H, S oraz V dla obliczonego postępu.

Kod klasy

Cały program do pobrania tutaj.

Na początek przeanalizujmy jakie pola będą nam potrzebne do działania:

    int pinR, pinG, pinB;
    int initH = 0, destH = 0;
    float initS = 0, destS = 0;
    float initV = 0, destV = 0;
    unsigned long startTime, transitionTime;
    bool inProgress = false;
    void (*onFinishListener)() = NULL;

Z pewnością przydatną informacją będzie mapowanie pinów dla odpowiednich kolorów. Następnie deklarujemy początkowe oraz końcowe wartości dla H, S i V. Musimy też przechowywać gdzieś informację o czasie rozpoczęcia animacji i czasie jej trwania, a także flagę mówiącą nam, czy animacja jest w trakcie, czy już się skończyła. Ostatnią zmienną jest wskaźnik do funkcji, która wykona się po zakończeniu animacji.

W standardowych bibliotekach C++ nie znalazłem funkcji signum, która była potrzebna do obliczeń, więc klasa definiuje ją w następujący sposób:

    int sgn(int num) {
      if(num < 0) return -1;
      if(num > 0) return 1;
      return 0;
    }

W konstruktorze klasy nie ma nic niezwykłego. Jedyne co robimy to ustawiamy pola z pinami oraz ustawiamy je jako wyjścia.

    RGBTransition(int r, int g, int b) {
      pinR = r;
      pinG = g;
      pinB = b;
      pinMode(r, OUTPUT);
      pinMode(g, OUTPUT);
      pinMode(b, OUTPUT);
    }

W klasie mamy 8 seterów do poszczególnych pól. Wszystkie 8 wygląda dokładnie tak samo, więc przedstawię tutaj kod tylko pierwszego z nich:

    RGBTransition& setTransitionTime(unsigned long time) {
      transitionTime = time;
      return *this;
    }

Jak widać zwracamy tutaj referencję do obiektu naszej klasy. Dlaczego nie może to być void? Ano dlatego, że dzięki takiemu zabiegowi jak powyżej będziemy mogli wywołać kilka metod „na raz” (np. obiekt.metoda1().metoda2().metoda3() ). Gdybyśmy zadeklarowali nasze settery jako void’y zawsze musielibyśmy się odnosić do zmiennej obiekt.

Metody setHSV() nie będę omawiał, gdyż jest to tylko implementacja algorytmu przedstawionego tutaj.

Zajmijmy się poważniejszą matematyką, czyli metodą doTransition().

    void doTransition(bool reset) {
      if(reset) {
        startTime = millis();
        inProgress = true;
      }
      float progress = (millis() - startTime) / (transitionTime * 1.0);
      if(inProgress && progress >= 1) {
        initH = destH;
        initS = destS;
        initV = destV;
        inProgress = false;
        if(onFinishListener != NULL) {
          (*onFinishListener)();
        }
      }
      
      if(!inProgress) return;

      int H = destH - initH;
      if(abs(H) < 180) {
        H = initH + (1.0 * H * progress);
      } else {
        H = initH + (-1.0 * sgn(H) * (360 - abs(H)) * progress);
        if(H < 0) H += 359;
      }

      float S = destS - initS;
      S = initS + (1.0 * S * progress);

      float V = destV - initV;
      V = initV + (1.0 * V * progress);
      
      setHSV(H, S, V);
    }

    void doTransition() {
      doTransition(false);
    }

Jak widać po nagłówku przyjmuje ona parametr reset typu bool. Jest on opcjonalny (jak widać na dole listingu metoda jest przeciążona i jako domyślną wartość ustawia false).

Co robi ten cały reset dowiadujemy się na samym początku metody – rozpoczyna on animację od początku.

Następnie obliczamy postęp animacji. Mnożenie przez 1.0 jest konieczne, gdyż wymusza na jednym z operandów dzielenia bycie typem zmiennoprzecinkowym. Jeżeli oba operandy byłyby liczbami całkowitymi mielibyśmy tam dzielenie całkowite, które dałoby nam w naszym przypadku wynik 0 lub 1.

      float progress = (millis() - startTime) / (transitionTime * 1.0);

Następnie sprawdzamy, czy animacja się już skończyła.

      if(inProgress && progress >= 1) {
        initH = destH;
        initS = destS;
        initV = destV;
        inProgress = false;
        if(onFinishListener != NULL) {
          (*onFinishListener)();
        }
      }

Jeżeli animacja trwa i postęp jest większy bądź równy 1 (czyli 100%) to ustawiamy końcowe wartości jako początkowe dla następnej animacji, ustawiamy flagę inProgress na false oraz wywołujemy metodę podaną przez użytkownika jako uchwyt do listenera sygnalizującego zakończenie animacji (jeżeli takowy został ustawiony).

Czas na trochę matematyki.

      int H = destH - initH;
      if(abs(H) < 180) {
        H = initH + (1.0 * H * progress);
      } else {
        H = initH + (-1.0 * sgn(H) * (360 - abs(H)) * progress);
        if(H < 0) H += 359;
      }

Najpierw musimy obliczyć naszą pośrednią wartość H. Najpierw musimy obliczyć odległość końca od początku razem ze znakiem. Jeżeli moduł z tej liczby jest mniejszy niż 180 (czyli połowa skali H od 0 do 360). To znaczy, że optymalnie będzie nam policzyć wartość pośrednią między tymi dwoma wartościami niż przechodzić przez 0.

Myślę, że powyższa ilustracja dobrze obrazuje to, co miałem na myśli :)

W pierwszym przypadku, kiedy nie przechodzimy przez 0 wystarczy do wartości dodatkowej dodać odległość pomnożoną przez postęp (teraz widać dlaczego obliczając H na początku zostawiliśmy znak). Przechodząc z wartości 240° do 120° będziemy musieli odejmować, natomiast w drugą stronę dodawać.

Drugi przypadek jest nieco bardziej skomplikowany, gdyż tutaj musimy odwrócić znak drugiego składnika, pomnożyć przez znak odległości dest od init oraz odległość odległości dest i init od liczby 360 (tak wiem, brzmi skomplikowanie). Na końcu jeżeli się okaże, że H jest mniejsze od 0 (czyli przeszło już przez owe 0) dodajemy 359 tak, aby zaczęło schodzić w dół skali. W drugą stronę nie musimy się przejmować, gdyż metoda setHSV dzieli parametr H modulo 360.

Uff przebrnęliśmy przez najgorsze :) Teraz już tylko obliczenie S i V no i jesteśmy w domu.

      float S = destS - initS;
      S = initS + (1.0 * S * progress);

Jak widać tutaj sprawa jest już wręcz banalna. Mamy wartości od 0 do 100% i nie możemy przejść przez początek skali, żeby znaleźć się na jej końcu. Wartość V obliczamy identycznie.

      setHSV(H, S, V);

Na koniec zostało już tylko przekazać obliczone wartości do metody setHSV i gotowe.

Przykładowe wykorzystanie

W naszym przykładowym programie będziemy na zmianę płynnie zwiększali kolor o 10 stopni i wygaszali diodę manipulując wartością V.

Na początek definiujemy sobie obiekt naszej klasy oraz aktualną wartość H:

RGBTransition led(PIN_RED, PIN_GRN, PIN_BLU);
int currentHue = 0;

Teraz w funkcji setup() możemy ustalić kilka początkowych wartości:

void setup() {
  led.setTransitionTime(200);
  
  led.setInitHue(0).setHue(currentHue);
  led.setInitSaturation(1).setSaturation(1);
  led.setInitValue(0).setValue(1);
  
  led.setOnFinishListener(fadeToBlack);
  led.doTransition(true);
}

Ustalamy czas animacji na 200 ms, kolor na czerwony, stałą saturację na 100% oraz początkową jasność na 0 i końcową na 100%. Dodajemy jeszcze uchwyt do funkcji, która wykona się po zakończeniu animacji.

W funkcji loop() wywołujemy tylko metodę doTransition bez parametrów na naszym obiekcie.

Na koniec jeszcze funkcje-uchwyty do onFinishListener:

void fadeToBlack() {
  led.setValue(0);
  led.setOnFinishListener(changeColor);
  led.doTransition(true);
}

void changeColor() {
  currentHue = (currentHue + 10) % 359;
  led.setHue(currentHue);
  led.setValue(1);
  led.setOnFinishListener(fadeToBlack);
  led.doTransition(true);
}

Jak widać zmieniają one owy listener na siebie nawzajem, tak więc będą się one wykonywały na zmianę.

Teraz możemy skompilować program, wgrać na Arduino i cieszyć się płynnym przejściem kolorów :)