• Witaj na Forum Arduino Polska! Zapraszamy do rejestracji!
  • Znajdziesz tutaj wiele informacji na temat hardware / software.
Witaj! Logowanie Rejestracja


Ocena wątku:
  • 4 głosów - średnia: 5
  • 1
  • 2
  • 3
  • 4
  • 5
Biblioteka Timers = koniec z uciążliwym delay()
#1
Witam
Ten tutorial ma wiele wspólnego z flustracją jaka często nas dopada, kiedy chcemy poszerzyć nasz programik o obsługę kolejnej fajnej funkcji i nagle okazuje się, że to nie jest takie proste :-)

Dotąd pisaliśmy programiki gdzie procesor wykonywał w pętli loop() kolejne instrukcje krok po kroku... w niekończącej się pętli (neverending story). Jak chcieliśmy nagle przerwać jakieś działanie to uciekaliśmy się do przerwań (zewnętrznych lub timera). 
Co się wtedy dzieje? - procesor zapamiętuje stan rejestrów, wykonuje skok do funkcji obsługi przerwania i następnie wraca do miejsca gdzie przerwał naszej pętli loop() działanie.
OK... ale jeśli byliśmy właśnie na przykład w pętli delay(5000) i po 300 ms. wywołaliśmy przerwanie, to po powrocie procesor i tak wykona swoje delay(4700) które pozostało... a tego na przykład nie chcemy.
Problem polega na tym, że nasz procek wykonuje program sekwencyjnie, czyli krok po kroku...
Natomiast nasz Windows, Linux czy Android (to także Linux) swoje programy opiera o tzw. obsługę wątków.
W dużym uproszczeniu system "nasłuchuje" co się dzieje z urządzeniami zarejestrowanymi w systemie i w zależności od tego co się wydarzy podejmuje określone działanie. Jeśli ruszysz myszą... system już wie że to zrobiłeś, gdzie jest myszka... Jeśli klikniesz myszką na ikonkę, system wie co to za ikonka i jakim klawiszem kliknąłeś :-)
To oczywiście duże uproszczenie, zwłaszcza dla API Windows. 
W każdym razie system w określonych odstępach czasu (im szybszy procek, tym częściej) odpytuje wszystkie zarejestrowane składniki i przejmuje kontrolę nad zdarzeniem, które z kolei potrafi obsłużyć.

W tym tutorialu postaram się przybliżyć nieco to zagadnienie i przedstawić przykład zastosowania tej właśnie techniki w naszych programach. To nie jest wcale takie trudne a jest alternatywą dla obsługi przerwań na przykład.
Postanowiłem podejść do tego problemu z innej strony - napisać program oparty o obsługę zdarzeń właśnie.
Celem nadrzędnym było zachowanie pełnej funkcjonalności BEZ UŻYCIA w funkcji loop() chociażby jednej funkcji delay()!!!

Przykład projektu: 
--------------------
Nasze przykładowe zadanie - zaprojektować urządzenie - wytrawiarkę płytek drukowanych - czyli połączenie stopera, termostatu z grzałką i mieszalnika w jedno. Każde z tych urządzeń ma mieć niezależne sterowanie, czyli jeśli np. silnik (mieszadło) wykonuje ruch w jedną stronę, to funkcja delay() nie może spowalniać stopera i jednocześnie jeśli będę chciał zmienić np . temperaturę zadaną to nie powinienem czekać w kolejce wykonywanych czynności w drzewku funkcji loop :-)

To zadanie można zrealizować za pomocą obsługi przerwań ( zewnętrzne lub timera)... ale jeśli za chwilę zapragnę jeszcze niezależnie zmieniać prędkość zmian kierunku ruchu silnika, to co? Kolejne przerwanie?

Proponuję tu zapoznać się z biblioteką Timers (moim zdaniem najlepszą i najprostszą z trzech podobnych - Timer, TimerOne i TimerTree). Ona pozwoli nam na zastosowanie techniki programowania opartej na obsłudze wątków.

Nie chcę tutaj powielać opisu... więc aby nie zanudzać więcej przedstawiam przykładowy kod z obszernym wstępnym komentarzem, który mam nadzieję wyjaśni większość ew. wątpliwości:

Komentarz:
-------------
Przykładowa implementacja programu opartego na obsłudze zdarzeń. Namiastka aplikacji będąca alternatywą dla programowania sekwencyjnego, krok, po kroku. Ten program nasłuchuje w określonych odstępach czasu informacji o stanie użytych w nim podzespołów (czujników, buttonów, silników itp.) i w zależności od stanu ich samych podejmuje określone działania.
Dzięki takiemu podejściu możliwa jest całkowita eliminacja uciążliwej funkcji delay(). Nie trzeba bowiem czekać aż wykona się czynność ograniczona funkcją delay(). Jest to równiez alternatywa dla obsługi przerwań. Przykładowo jeśli uruchomiono silnik na czas 2 sekund, to bez obsługi przerwań (zewnętrznych lub timera) nie było prostego sposobu na wykonanie innego zadania w trakcie ruchu silnika.

Ten program korzysta z biblioteki Timers, która moim zdaniem jest chyba najłatwiejsza do zastosowań i może być powszechnie używana przez wielu majsterkowiczów i czytelników naszego forum.
Zamieszczony przykład jest softem do projektu "automatycznej wytrawiarki", czyli termostatu z mieszalnikiem.

Projekt umożliwia:
- ustawienie zadanej temperatury procesu wytrawiania płytek drukowanych (tu w zakresie 10-80 st.C)
- automatyczne nadzorowanie grzałki ( włącz - wyłącz) - funkcja termostatu
- dynamiczne, niezależne załączanie lub wyłączanie mieszalnika - funkcja mieszania roztworu metodą wibracyjną (szybka zmiana kierunku obrotu silnika)
- dynamiczną, niezależną zmianę czasu ruchu silnika w mieszalniku - regulacja czasu ruchu silnika.
- kontrolę czasu trwania procesu - funkcja stopera (tutaj max 60 minut)

To wszystko realizowane jest za pomocą tylko 3 przycisków: UP, DOWN i OK.

Zatem program realizuje kilka jednoczesnych procesów: odlicza czas, mierzy aktualną temperaturę, załącza/wyłącza grzałkę, steruje ruchem mieszalnika i szybkością zmian kierunku obrotów oraz w czasie rzeczywistym umożliwia zmianę parametrów: temperatury zadanej i prędkości mieszalnika bez opóźnień i wpływu na wykonywanie innych zadań. Niezbędną dotąd funkcję delay i przerwania w programie zastąpiono obsługą osobnych wątków w określonych odstępach czasu.

Program jest przykładem zastosowania biblioteki Timers.

OPIS BIBLIOTEKI (skrócony):
----------------------------
Czym biblioteka Timers różni się od biblioteki Timer, TimerOne czy TimerTree? 
Przede wszystkim prostotą użycia jej w programie. Zasadniczo korzystamy z 4 wygodnych funkcji składowych klasy Timers:

1) Konstruktor - inicjalizacja obiektu klasy Timers. 
Przykład:
Timers <8> akcja; - Powołujemy do życia obiekt klasy Timers o przykładowej nazwie akcja, który może obsłużyć 8 niezależnych wątków (zdarzeń)

2) Funkcja attach(nr wątku, interwał wywołania, funkcja obsługi), gdzie:
- numer wątku, to numer jednego z 8 wcześniej zdefiniowanych w konstruktorze wątków;
- interwał - odstęp czasu w ms. W tych odstępach będzie wywoływana funkcja obsługi
- funkcja obsługi - nazwa funkcji, jak ma być wykonywana.
W tym zakresie funkcja attach jest łudząco podobna do funkcji attachInterrupt.
Przykłady:
akcja.attach(2,5000,pokazTemp); - co 5 sekund wątek 3 (liczymy od 0) wywołuje funkcję pokazTemp()
akcja.attach(0, 1000, pokazCzas); - co 1 sekundę wątek pierwszy wywołuje funkcję pokazCzas()
akcja.attach(1,0,flopKierunek); - definiujemy wątek nr 2, ale nie mamy na razie zamiaru z niego korzystać - interfał =0 

UWAGA!!!
Funkca attach musi być zainicjowana w funkcji setup() dla każdego z wątków.
Jeśli nie mamy zamiaru od razu korzystać z danego wątku, to w funkcji attach ustawiamy interwał na 0

3) Funkcja updateInterval(nr wątku, akt interwał), gdzie:
- nr wątku, to numer jednego z 8 wcześniej zdefiniowanych w konstruktorze wątków;
- akt interwał - dynamiczna zmiana czasu wywoływania funkcji, w szczególnym przypadku dla interwał=0 wyłączamy obsługę wątku.
Przykłady:
akcja.updateInterval(2,0); - zatrzymanie obsługi wątku nr 3
akcja.updateInterval(4,189); - zmiana lub ustawienie dla wątku nr 5 czasu wywoływania funkcji obsługi na 189 ms.

4) funkcja process() - wywoływana w pętli loop, uruchamia globalną obsługę wszystkich zadeklarowanych
w konstruktorze wątków
Przykład:
akcja.process();

W programie sterowanie silnikiem jest realizowane za pomocą 2 pinów cyfrowych, w oparciu o zasadę:
pin A - LOW, pin B - LOW - STOP silnika
pin A - HIGH, pin B - LOW - ruch w lewo
pin A - LOW, pin B - HIGH - ruch w prawo
Zdaję sobie sprawę, że akurat takie sterowanie wymaga specjalnego zabezpieczenia mostka H, który przy dużych prądach silnika jest niestety wymagany.
Dla celów testowych zamiast silnika do płytki podłączono 2 diody LED połączone równolegle
(przeciwstawnie A-K przez rezystor 220 OHm). Dzięki temu będą zapalały się na przemian symulując pracę silnika.

Ponadto w funkcji pokazTemp() użyto czujnika temperatury podpiętego do wejścia A1. 
Jeśli posiadasz inny czujnik należy zmodyfikować jego obsługę i wzór przeliczający jednostki
oraz dodać ew. bibliotekę obsługi czujnika.

OBSŁUGA PROGRAMU:
Po załączeniu zasilania widzimy ekran powitalny gdzie na wstępie ustawiono zadaną temperaturę roztworu na 40 st.C
Klawiszami DOWN - UP możemy modyfikować tą temperaturę ze skokiem 1 st. C
Klawisz OK zatwierdza zmiany i zostaje uruchomiony proces wytrawiania (start stopera, start mieszalnika, nagrzewanie)
Co 1 sek. pokazywana jest zmiana czasu, co 5 sekund aktualizowana jest temperatura roztworu,
a ruch silnika w jedną stronę trwa 0,6 sek. (wstępnie zadeklarowana zmienna cz=600)
Klawiszem OK możemy teraz zatrzymać mieszalnik. Ponowne naciśnięcie OK uruchamia nasz mieszalnik. 
Naciśnięcie klawisza DOWN lub UP podczas pracy mieszalnika umożliwia zmianę czasu pracy silnika ze skokiem 50 ms.
Naciśnięcie klawisza DOWN lub UP podczas postoju mieszalnika umożliwia zmianę zadanej temperatury ze skokiem 1 st.C

SPIS UŻYTYCH PINÓW, płytka Arduino UNO R3:
-----------------------------------
8,9,4,5,6,7 - LCD
0 - klawisz DOWN
1 - klawisz UP
2 - klawisz OK
13 - przekaźnik grzałki
11,12 - piny silnika (do podłączenia mostka H)
A1 - czujnik temperatury

3,10 - wolne piny 
-----------------------------------
A oto przykładowy kod wytrawiarki: (...by wojtekizk wojtek@warcaby.lh.pl)
Cytat:#include <LiquidCrystal.h> // dołączona biblioteka LiquidCrystal
#include <Timers.h> // dołączona biblioteka Timers (konieczna do poprawnej pracy ... jest w załączniku)
Timers <8> akcja; // na poczatek 8 niezależnych wątków (procesów, zadań, procedur, akcji itp.)
LiquidCrystal lcd(8,9,4,5,6,7); // definiujemy LCD
#define UP 1                     // klawisz UP na pinie 1
#define DOWN 0                   // klawisz DOWN na pinie 0
#define OK 2                     // klawisz OK na pinie 2
#define przekaznik 13            // przekaźnik na pinie 13
#define motor1Pin 12             // motor1Pin na pin 12 
#define motor2Pin 11             // motorPin2 na pin 11 
volatile boolean kier=false// kierunek ruchu mieszalnika
volatile boolean startT=false// start mieszalnika
long czas=0; // czas od chwili startu 
long s=0; // chwila startu
int minuty=0, sekundy=0; // chyba jasne :-)
int ustaw=40; // zadana temperatura początkowa
volatile int cz=600; // zadany poczatkowy czas ruchu mieszalnika
// --- funkcje dla ruchu mieszalnika -----------------------------------
void r0(){digitalWrite(motor1Pin,LOW); digitalWrite(motor2Pin,LOW); } // zatrzymanie mieszalnika
void rP(){digitalWrite(motor1Pin,LOW); digitalWrite(motor2Pin,HIGH);} // ruch mieszalnika w lewo
void rT(){digitalWrite(motor1Pin,HIGH);digitalWrite(motor2Pin,LOW); } // ruch mieszalnika w prawo
// --- funkcja ustawienia zadanej temperatury --------------------------
void setTemp() // tylko w tej jedynej funkcji użyłem delay, i tylko dlatego aby zapobiec drganiom styków
                     // oraz aby spowolnić szybkość zwiększania lub zmniejszania wymaganej temperatury. 
                     // Ta funkcja jest uruchamiana w setupie zanim w loopie uruchomię obsługę zdarzeń :-)
{
  while(digitalRead(OK)!=LOW// dopóty nie wcisniemy klawisza OK, ustawiamy wymaganą temperaturę roztworu
  {
    // poniżej inkrementacja (zwiększanie) lub dekremantacja (zmniejszanie) wymaganej temperatury w procesie 
    if(digitalRead(UP)==LOW){delay(30);if(digitalRead(UP)==LOW) {ustaw++;if(ustaw>80)ustaw=80;lcd.setCursor(12,1); lcd.print(ustaw);}} 
    if(digitalRead(DOWN)==LOW){delay(30);if(digitalRead(DOWN)==LOW) {ustaw--;if(ustaw<10)ustaw=10;lcd.setCursor(12,1); lcd.print(ustaw);}}
    delay(80); // raczej konieczne dla mojego refleksu :-), owo spowolnienie...
  }
  startT=true// zezwolenie na start mieszalnika 
  lcd.clear();lcd.setCursor(0,0);lcd.print("TIME TEMP MOTOR"); // kosmetyka LCD, pierwsza linia 
  pokazCzas();pokazTemp(); // a w drugiej linii pokazuję stoper i akt. temperaturę
}  
// --- reakcja na wciśnięty przycisk OK  w pętli loop -------------------
void buttonOK()
{
if(digitalRead(OK)==LOW){startT=!startT;akcja.updateInterval(2,5000);}
// naprzemienne wyłacza lub załącza mieszalnik oraz uruchamia proces pokazywania akt. temperatury
}
// --- reakcja na wciśniety przycisk DOWM w pętli loop ------------------
void buttonDOWN()
{
if(digitalRead(DOWN)==LOW)
  {
  if(startT==true){cz-=50;if(cz<50)cz=50;} // dynamicznie zmniejsza czas ruchu mieszalnika w krokach co 50 ms.
  else
    {
     akcja.updateInterval(2,0); // zatrzymuje proces pokazywania akt. temperatury
     ustaw--;if(ustaw<10)ustaw=10; // dynamicznie zmniejsza wymaganą temperaturę w krokach co 1 st.
     lcd.setCursor(6,1);lcd.print(" ");lcd.setCursor(6,1);lcd.print(ustaw);lcd.print(" C"); // kosmetyka LCD
    } 
  }
}
// --- reakcja na wciśnięty przycisk UP w pętli loop --------------------
void buttonUP()
{
if(digitalRead(UP)==LOW)
  {
  if(startT==true){cz+=50;if(cz>3000)cz=3000;}// dynamicznie zwiększa czas ruchu mieszalnika w krokach co 50 ms.
  else 
    {
      akcja.updateInterval(2,0); // zatrzymuje proces pokazywania akt. temperatury
      ustaw++;if(ustaw>80)ustaw=80; // dynamicznie zmniejsza wymaganą temperaturę w krokach co 1 st.
      lcd.setCursor(6,1);lcd.print(" ");lcd.setCursor(6,1);lcd.print(ustaw);lcd.print(" C"); // kosmetyka LCD
    }
  }
}
// --- zmiana kierunku ruchu mieszalnika + kosmetyka LCD ----------------
void flopKierunek()
{
  kier=!kier;r0();lcd.setCursor(11,1);lcd.print(" OFF "); // zatrzymanie mieszalnika
  if(kier==true){rP();lcd.setCursor(11,1);lcd.print(" -->");} // ruch do przodu + kosmetyka LCD
  else {rT();lcd.setCursor(11,1);lcd.print(" <--");} // ruch do tyłu + kosmetyka LCD
}
// --- pokaz czasu pracy ------------------------------------------------
void pokazCzas()
{
  char buf[20]; memset(buf,0,sizeof(buf)); // zamiast pozycjonowania LCD proponuję to właśnie 
  czas=(millis()-s)/1000; // czas w sekundach
  minuty=czas/60;sekundy=czas%60;          // obliczamy mniuty i sekundy
  if(minuty>59){minuty=0;sekundy=0;} // po przekroczeniu 60 minut liczymy od nowa
  snprintf(buf,sizeof(buf),"%02d:%02d",minuty,sekundy); // sprytna funkcja (radzę poczytać o niej :-)
  lcd.setCursor(0,1);lcd.print(buf); // kosmetyka LCD
}
// ---  pokaz aktualnej temperatury --------------------------------------
void pokazTemp()
{
  int sensorValue = analogRead(A1); // czujnik temperatury na pin A1
  float temp = sensorValue * 0.48828125; 
  //float temp = sensorValue * (5.0 * 100.0/1023.0); // przeliczamy (wzór zalezy od czujnika)
  lcd.setCursor(6,1);lcd.print(" ");lcd.setCursor(6,1);lcd.print((int)temp);lcd.print(" C"); // kosmetyka LCD 
  if(temp < ustaw) { digitalWrite(przekaznik,HIGH);lcd.setCursor(5,0);lcd.print("*");} // właczamy grzałkę i gwiazdkę na LCD
  if(temp > ustaw+1){ digitalWrite(przekaznik,LOW); lcd.setCursor(5,0);lcd.print(" ");} // lub nie (histgereza 1 st.C)
}
// --- ekran powitalny ---------------------------------------------------
void kosmetykaLCD()
{
lcd.clear();lcd.setCursor(0,0);lcd.print("<Czarodziej PCB>");
lcd.setCursor(0,1);lcd.print("Temp.roztw.=");lcd.print(ustaw);lcd.print(" C");
}
// ------------------------------------------------------------------------
void setup()
{
  pinMode(motor1Pin,OUTPUT);digitalWrite(motor1Pin,LOW); // stop motor
  pinMode(motor2Pin,OUTPUT);digitalWrite(motor2Pin,LOW); // stop motor
  pinMode(UP,INPUT_PULLUP); // klawisz UP
  pinMode(DOWN,INPUT_PULLUP); // klawisz DOWN 
  pinMode(OK,INPUT_PULLUP); // klawisz OK 
  lcd.begin(16,2); kosmetykaLCD(); // powitanie :-)
  setTemp();                        // na początku ustawiam wymaganą temperaturę 
  s=millis(); // sędzia odpalił na start :-), utrwalamy tę chwilę w zmiennej s
  // Teraz najważniejsze :-)
  akcja.attach(0, 1000, pokazCzas); // Wątek 1: pokazuje czas co 1 sekundę
  akcja.attach(1,0,flopKierunek); // Wątek 2: ruch mieszalnika, 0 oznacza, że na razie zatrzymany ten proces
  akcja.attach(2,5000,pokazTemp); // Wątek 3: pokazuje temperaturę co 5 sek.
  akcja.attach(3,200,buttonOK); // Wątek 4: sprawdza stan klawisza OK dla START-STOP mieszalnika, co np. 200 ms. 
  akcja.attach(4,189,buttonDOWN); // Wątek 5: sprawdza stan klawisza DOWN co np. 189 ms. 
  akcja.attach(5,211,buttonUP); // Watek 6: sprawdza stan klawisza UP co np. 211 ms.
  // i tu mała uwaga... aby uniknąć sytuacji jednoczesnego uruchamiania kilku wątków dobrze jest używać liczb pierwszych
  // jako drugiego parametru funkcji attach :-) Nie jest to jednak krytyczne.
}
// -------------------------------------------------------------------------
void loop()
{
akcja.process(); // inicjalizacja lub aktualizacja wszystkich procedur(wątków, zdarzeń itp.)
if(startT==true){akcja.updateInterval(1,cz);} // a teraz uruchomiłem ruch mieszalnika ze zmianą co cz [ms]
else {r0();lcd.setCursor(11,1);lcd.print(" OFF ");akcja.updateInterval(1,0);} // 0 oznacza stop akcji (dla ruchu mieszalnika)
}
// =========================================================================

W załączniku biblioteka Timers do zaimportowania (chodzą słuchy, że ta stara nie działa jak trzeba, więc tamtą trzeba usunąć i w to miejsce nowa :-)
Pozdrawiam ...i czekam na ew. sugestie, ten wątek może być przyczynkiem do wielu rzeczowych dyskusji :-)


Załączone pliki
.zip   Timers.zip (Rozmiar: 1,013 bajtów / Pobrań: 1,173)
 
Odpowiedź
#2
Kurde czuję, że już niedługo czeka mnie identyczna zabawa i będę miał dużo pytań bo jeszcze sporo nie rozumiem Smile
 
Odpowiedź
#3
Ogarnięte działa !! Smile Dziękuję Bardzo !!
 
Odpowiedź
#4
Witam,

Możecie mi pomóc w ogarnięciu tej biblioteki TIMERS...
Program testowy ma tylko mrugać diodą D13.
Niestety nic się nie dzieje...

Kod:
//Timers.

#include <Timers.h>
Timers <2> akcja;

int led1 = 13;

void setup()
{              
 pinMode(led1, OUTPUT);      
 akcja.attach (0, 500, dioda1);
 akcja.attach (1, 500, dioda2);
}

void loop()
{
 akcja.process();
}

void dioda1()
{
 digitalWrite (led1, HIGH);
}

void dioda2()
{
 digitalWrite (led1, LOW);
}
Jeżeli pomogłem, to poproszę o punkt reputacji Big Grin
 
Odpowiedź
#5
Cytat:Niestety nic się nie dzieje...
Zawsze coś się dzieje ...

Kod:
akcja.attach (0, 500, dioda1);
akcja.attach (1, 500, dioda2);
Kod:
Dwie funkcje wywoływane są w tym samym czasie jedno 500 zmień np na 1000
 
Odpowiedź
#6
Faktycznie... gapa ze mnie Smile
Dzięki.
Jeżeli pomogłem, to poproszę o punkt reputacji Big Grin
 
Odpowiedź
#7
(13-01-2016, 17:45)wojtekizk napisał(a): Witam
Ten tutorial ma wiele wspólnego z flustracją jaka często nas dopada, kiedy chcemy poszerzyć nasz programik o obsługę kolejnej fajnej funkcji i nagle okazuje się, że to nie jest takie proste :-)
Dzięki za namiary i przykłady zastosowania biblioteki Timers.h. Jest rzeczywiście genialnie prosta i łatwa w użyciu. I co ważne działa bez problemu w różnych konfiguracjach programowych i na różnych procesorach (u mnie Atmel i ESP).
Może znasz równie prostą i przyjazną bibliotekę obsługi zdarzeń coś ala button ale nie ograniczoną tylko do zmian na portach procesora ale potrafiąca obsłużyć "zmiany" dowolnej zmiennej w programie (szerzej niż tylko zmienne typu bool). Oczywiście też działającej w poolingu i zawierającej ca 90 linii kodu Smile.
Krzycho
 
Odpowiedź
#8
Trochę różnych wariacji programowych z biblioteką Timers.h  zamiaściłem tu>>>
Proszę o dodanie do opisu biblioteki funkcji setInterval.
 
Odpowiedź
#9
(01-11-2016, 15:13)krzyspx napisał(a):
(13-01-2016, 17:45)wojtekizk napisał(a): Witam
Ten tutorial ma wiele wspólnego z flustracją jaka często nas dopada, kiedy chcemy poszerzyć nasz programik o obsługę kolejnej fajnej funkcji i nagle okazuje się, że to nie jest takie proste :-)
Dzięki za namiary i przykłady zastosowania biblioteki Timers.h. Jest rzeczywiście genialnie prosta i łatwa w użyciu. I co ważne działa bez problemu w różnych konfiguracjach programowych i na różnych procesorach (u mnie Atmel i ESP).

Czy biblioteka zadziała także na Attiny85?
 
Odpowiedź
#10
Mam pytanie do autora tematu, czy zamiast klawiszy można użyć wyświetlacza dotykowego aby sterować pracą urządzenia?
 
Odpowiedź
  


Skocz do:


Przeglądający: 1 gości