Poprzednio poruszyliśmy temat współbieżności w obrębie jednego procesu (wielowątkowość), teraz zajmiemy się tematem współbieżności w obrębie systemu operacyjnego (wielozadaniowość).
Uruchomimy proces prosto z aplikacji. Następnie omówimy mechanizmy wspierane przez Qt, które pozwalają na komunikację między procesami.
W części 7, dotyczącej współbieżności, przedstawiliśmy definicję wielozadaniowości. Przypomnijmy ją: wielozadaniowość (ang. multitasking) to cecha systemu operacyjnego umożliwiająca równoczesne wykonywanie więcej niż jednego procesu (programu).
Kiedy stosować wielozadaniowość?
Wielozadaniowość otacza nas na co dzień – pewnie w tym momencie, gdy to czytasz, system operacyjny, z którego korzystasz, właśnie ją realizuje, pozwalając Twojej przeglądarce na działanie. Większość współczesnych przeglądarek wykorzystuje wiele procesów – przykładowo przeglądarka Chrome wykorzystuje osobne procesy dla każdej zakładki, wtyczki lub każdego rozszerzenia. Jedną z zalet takiego rozwiązania jest to, że gdy proces odpowiedzialny np. za dane rozszerzenie ulegnie awarii, to nie spowoduje on awarii całej przeglądarki.
Gdyby zastosowano tutaj wielowątkowość, to wtedy awaria jednego wątku spowodowałaby awarię całego procesu przeglądarki.
Często korzystamy z wielozadaniowości nieco nieświadomie, np. pisząc program w QtCreatorze, używamy procesu, jakim jest sam QtCreator, następnie klikając Build, uruchamiamy kompilację, co powoduje uruchomienie procesu kompilatora, np. GCC. Sam QtCreator nie bierze udziału w kompilacji – robi to konkretny kompilator.
Kiedy zatem stosować wielozadaniowość? Wtedy gdy rozbicie naszego problemu na kilka procesów da nam lepsze rezultaty lub większą stabilność. Innym przykładem użycia wielozadaniowości będzie konieczność skorzystania z innego gotowego programu, np. unzip do rozpakowywania archiwów czy AVRDUDE do wgrania programu na nasz mikrokontroler.
Czym jest QProcess?
W Qt klasą, która pozwala na uruchamianie procesów i komunikację z nimi, jest klasa nazwana QProcess. Uruchomienie procesu jest niezwykle proste: aby rozpocząć nowy proces, wystarczy podać nazwę programu, argumenty, z jakimi chcemy go wywołać, i uruchomić proces. Więcej informacji o klasie znajdziemy w tym miejscu dokumentacji.
Uruchamianie procesów
Załóżmy, że chcemy w naszej aplikacji dowiedzieć się, jaką wersję AVRDUDE posiadamy na komputerze. W tym celu w terminalu możemy uruchomić:
Shell
1
avrdude-v
Wynikiem będzie informacja zwrócona przez AVRDUDE:
Sprawdzenie wersji programu AVRDUDE
Użytkownicy Linuksa muszą zainstalować AVRDUDE za pomocą: sudo apt install avrdude. Potem, aby mieć dostęp do portu, należy wywołać: sudo usermod -a -G dialout <username>, gdzie za <username> wstawiamy nazwę użytkownika, następnie trzeba zrestartować system.
Dla użytkowników Windowsa najszybszą drogą będzie np. zainstalowanie Arduino IDE, które dostarcza program AVRDUDE wraz z wszystkimi potrzebnymi plikami. Dla standardowych ustawień instalacji Arduino IDE katalog z AVRDUDE znajdziemy w lokalizacji:
Teraz zajmiemy się uzyskaniem tej informacji w naszym programie. W tym celu stwórzmy nowy projekt (typu Qt Quick Application). Następnie w pliku main.cpp dodajmy poniższe linie (omówienie tego kodu znajduje się w dalszej części wpisu).
Po jego uruchomieniu powinniśmy w oknie Application Output zobaczyć:
Wypisanie wersji AVRDUDE w programie Qt
W programie zastosowano warunkową kompilację programu, korzystając ze zdefiniowanych przez Qt definicji dla różnych systemów. Tym samym kompilując ten program na Linuksie lub Windowsie, nie musimy niczego zmieniać – w zależności od systemu zostanie użyty odpowiedni fragment kodu.
Nazwę programu, który chcemy uruchomić, podajemy za pomocą metody setProgram(). Natomiast listę argumentów podajemy jako typ QStringList za pomocą metody setArguments().
Linię:
C++
1
avrdude.setArguments(QStringList()<<"-v");
Można też zapisać następująco:
C++
1
2
3
QStringList _args;
_args<<"-v";
avrdude.setArguments(_args);
Za pomocą metody setProcessChannelMode() ustawiamy zachowanie kanałów strumieni standardoutput i standard error. Więcej informacji o możliwych trybach znajdziemy w tej dokumentacji. Ja ustawiłem tutaj tryb MergedChannels, ponieważ AVRDUDE wynik wywołania avrdude -v przekazywał na wyjście strumienia standarderror. Po ustawieniu trybu MergedChannels mogę odczytać połączone wyjścia obu strumieni za pomocą metody readAll(). Klasa QProcess umożliwia nam niezależny odczyt informacji z obu strumieni za pomocą metod: readAllStandardOutput() i readAllStandardError().
Teraz skorzystaliśmy z blokującego API klasy QProcess (używaliśmy metody waitForFinished()), później wykorzystamy asynchroniczne API.
Wgrywanie programu do uC z poziomu aplikacji
Stwórzmy nowy program, za pomocą którego będziemy mogli wgrać do mikrokontrolera podany przez nas plik hex. Chcemy mieć też możliwość wybrania portu i podgląd wyjścia ze strumieni AVRDUDE. Aby zrozumieć dokładnie, co chcemy osiągnąć, zobaczmy na przykładowy interfejs takiego programu:
Działanie programu współpracującego z AVRDUDE
Powyższy zrzut przedstawia zamierzone działanie programu. Klikając przycisk Search, znajdujemy podłączone do komputera urządzenia – korzystamy z klasy napisanej w C++ – następnie umieszczamy nazwy portów w kontrolce ComboBox, a obok niej liczbę znalezionych urządzeń. Gdy nie znajdziemy żadnego urządzenia, część interfejsu jest nieaktywna.
Po wybraniu portu i podaniu ścieżki do programu dla naszego mikrokontrolera odblokowany zostanie przycisk Upload – odblokowanie następuje wtedy, gdy podana ścieżka zawiera rozszerzenie .hex. Kliknięcie przycisku rozpoczyna uruchomienie procesu AVRDUDE, co w ostateczności powoduje wgranie programu do pamięci mikrokontrolera.
Aby nieco uprościć program, zastosowane zostało blokujące API przy uruchamianiu nowego procesu. Rozbudujemy program później, gdy omówimy jego najważniejsze elementy. Stworzyłem dwie klasy: SerialPortDevices i ProcessRunner. Zadaniem klasy SerialPortDevices jest zwrócenie listy nazw portów w postaci typu QStringList. Wykorzystałem w niej możliwości klasy QSerialPortInfo, którą poznaliśmy we wcześniejszych częściach.
Zwróć uwagę na charakterystyczną postać pętli for – jest to tzw. range-based for loop wprowadzona w C++11, która w połączeniu z auto znacznie upraszcza iterowanie po kontenerach. Więcej informacji znajdziemy w tej dokumentacji. Inną charakterystyczną rzeczą jest jawne poinformowanie kompilatora, aby sam wygenerował dla nas domyślną implementację konstruktora przez zastosowanie default.
Natomiast w tym wypadku moglibyśmy pominąć całkowicie tę linię – kompilator i tak wygenerowałby implementację tego konstruktora.
Zadaniem klasy ProcessRunner jest uruchomienie procesu AVRDUDE, co wymaga kilku dodatkowych informacji (nazwy portu i ścieżki do programu dla mikrokontrolera), które będą zależały od użytkownika, a także innych parametrów wywołania AVRDUDE (więcej o tym później). Po zakończeniu wgrywania klasa emituje sygnał, który zawiera zawartość obu strumieni:
Parametry wywołania AVRDUDE, których użyłem w programie, pochodzą z informacji, które są drukowane w konsoli Arduino IDE. Na potrzeby tego przykładu korzystałem z płytki Arduino UNO z ATmegą 328P na pokładzie. Aby uzyskać plik z programem na mikrokontroler i parametry wywołania AVRDUDE, musimy kilka rzeczy włączyć oraz „wyklikać” z poziomu Arduino IDE.
W celu włączenia tzw. verbose output, czyli dodatkowych informacji drukowanych w konsoli programu Arduino IDE, musimy: kliknąć File > Preferences > Show verbose output during i zaznaczyć compilation oraz upload.
Po włączeniu verbose output, gdy uruchomimy wgrywanie programu w konsoli, zobaczymy szereg ciekawych informacji – nas interesuje informacja z linii 5:
gdzie za <path/to/avrdude.conf> wstawiamy ścieżkę do pliku konfiguracyjnego, za <port> numer portu z naszym uC, a za <program.hex> ścieżkę do programu na mikrokontroler.
W przypadku Linuksa pomijamy parametr z plikiem konfiguracyjnym. Aby poznać znaczenie i to, za co odpowiada każdy parametr, odsyłam do tej dokumentacji AVRDUDE. Drugim istotnym elementem jest uzyskanie pliku, który możemy wgrać na mikrokontroler.
W tym celu w Arduino IDE klikamy Sketch > Export compiled binary. Spowoduje to wygenerowanie plików w formacie .hex, które będą znajdować się w katalogu z naszym programem na mikrokontroler.
Podczas opisywanych testów wykorzystałem wersje oznaczone jako:
Shell
1
<nazwa>.ino.with_bootloader.standard.hex.
Wróćmy teraz do kodu naszego programu. W pliku main.cpp rejestrujemy instancje obu klas w silniku QML – tak samo jak w poprzednich częściach.
Do budowy interfejsu zastosowałem layouty. Jest to zestaw typów QML używanych do rozmieszczania elementów w interfejsie użytkownika. W przeciwieństwie do elementów pozycjonujących (Row, Column itp.) layouty mogą również zmieniać rozmiar swoich elementów. To sprawia, że dobrze nadają się do budowy skalowalnych interfejsów.
Ważniejszym elementem pliku main.qml może być sprawdzanie, czy dany string zawiera fragment innego stringa – służy do tego funkcja match():
Kolejnym ciekawym elementem jest stworzenie przewijanego pola tekstowego – za pomocą typu ScrollView i umieszczenia w nim typu TextArea:
C++
1
2
3
4
5
6
7
8
9
10
11
ScrollView{
id:scrollView
Layout.fillHeight:true
Layout.fillWidth:true
TextArea{
id:textAreaOutput
// ..
}
}
Qt posiada wbudowany system tłumaczeń – więcej informacji znajdziemy w tej dokumentacji. W plikach możemy spotkać się z poniższym zapisem. Za pomocą funkcji qsTr() i innych oznaczamy string enter port value jako przeznaczony do tłumaczenia.
C++
1
TextField{placeholderText:qsTr("enter port value")}
Qt posiada specjalne narzędzia do tworzenia plików tłumaczeń – w takich narzędziach zobaczymy m.in. string enter port value, który będzie stanowił bazę do tłumaczeń na inne języki.
Aby ukończyć program, musimy wykorzystać asynchroniczne API klasy QProcess zamiast blokującego. W tym celu w klasie ProcessRunner musimy dokonać kilku zmian i dodać kilka nowych elementów. Nowa zawartość klasy ProcessRunner:
Istotne fragmenty to konstruktor klasy, gdzie zdefiniowane zostały połączenia między obiektem QProcess a naszą klasą. Połączyliśmy sygnały started(), errorOccurred() ifinished() oraz wspomniane wcześniej readyReadStandardError() i readyReadStandardOutput(). Sygnały errorOccured() i finished() mogą zapewnić nam istotne informacje w przypadku problemów.
Sygnał finished() jest przeładowany – posiada dwie implementacje – dlatego stosując zapis funktorowy, musimy wskazać kompilatorowi, o którą z nich nam chodzi:
Zakomentowany fragment to inne możliwe sposoby na wykonanie tego połączenia: pierwszy to znany sposób za pomocą makr SIGNAL() i SLOT(), drugi sposób to wykorzystanie zapisu funktorowego i lambdy jako slotu.
W metodzie run() usunęliśmy wywołanie blokującej metody – waitForFinished() – oraz emisję sygnału, dodaliśmy za to czyszczenie zawartości QStringa outputString. Pozostałe zmiany to zdefiniowanie odpowiednich slotów i ich implementacji oraz metody appendToOutputStringAndEmit(), która przejmuje wyjście z obu strumieni i emituje sygnał z ich zawartością.
Tym samym wykorzystaliśmy w pełni możliwości asynchronicznego API klasy QProcess, które teraz nie blokuje naszego interfejsu i na bieżąco przekazuje nam informacje. Działanie programu:
Uruchamianie niezależnych procesów
Dotychczas uruchomiliśmy procesy z poziomu naszej aplikacji w taki sposób, że każdy uruchomiony proces był traktowany jako podrzędny w stosunku do naszej aplikacji – child process. Zamknięcie głównej aplikacji spowoduje przerwanie wszystkich (tych, które nadal działają) podrzędnych procesów. Czasami znajdziemy się w sytuacji, kiedy będziemy musieli uruchomić jakiś proces i odłączyć się od niego. Wtedy zamknięcie aplikacji nie spowoduje przerwania nowo uruchomionego procesu.
Aby uruchomić niezależny proces, zamiast metody start() możemy wywołać jedną z trzech przeładowanych niestatycznych metod klasy QProcess –startDetached() – lub użyć jednej z dwóch metod statycznych – startDetached().
Uruchomimy niezależny proces AVRDUDE, który wgra program do mikrokontrolera, ale przestaniemy teraz posiadać informację o stanie procesu, o tym, czy się rozpoczął/zakończył, a także przestajemy mieć podgląd wyjścia ze strumieni procesu.
W przypadku AVRDUDE uruchomionego jako odłączony proces nie mamy zbyt dużo możliwości na poznanie informacji o stanie tego procesu. Jednak w sytuacji, gdybyśmy uruchomili swój program jako odłączony proces, możemy zaimplementować w naszych programach mechanizm komunikacji międzyprocesowej – Inter-Process Communication (IPC), aby móc je ze sobą skomunikować.
Technologie Inter-Process Communication w Qt
W informatyce komunikacja międzyprocesowa odnosi się głównie do mechanizmów zapewnianych przez system operacyjny w celu umożliwienia procesom zarządzania udostępnionymi danymi. Qt wspiera szereg różnych mechanizmów komunikacji międzyprocesowej – pełną listę technologii wraz z opisami znajdziemy w tej dokumentacji.
My skorzystamy z komunikacji międzyprocesowej zrealizowanej za pomocą TCP/IP.
Przykład zastosowania IPC
Zbudujemy dwa programy – jeden będzie odgrywał rolę serwera, drugi rolę klienta. Naszym celem jest stworzenie czatu za pomocą tych dwóch programów, które skomunikujemy dzięki mechanizmowi IPC, jakim może być TCP/IP, tak aby ostatecznie stworzyć narzędzie do rozmowy między dwoma osobami. Przykład działania obu programów przedstawia poniższe zdjęcie:
Przykład działania IPC
Wykorzystamy klasy QTcpServer i QTcpSocket. Uruchomienie serwera TCP w Qt to zaledwie kilkanaście linii kodu, rzecz wygląda tak samo przy połączeniu się klienta z serwerem za pomocą socketu TCP – to również kilkanaście linii kodu. Nie będziemy tutaj omawiać, jak działają protokoły TCP/IP, wrócimy do tego innym razem. Przejdźmy do kodu obu programów.
Kod programu klienta
Kod klasy Client korzysta z klasy QTcpSocket, której obiekt wykorzystuje m.in. do nawiązania połączenia z serwerem, przesyłania i odbierania danych:
Kluczowymi fragmentami tej klasy są metody connectToServer() oraz makeConnections(). Pierwsza z nich wywołuje metodę klasy QTcpSocket – connectToHost(), do której przekazujemy nazwę hosta, np. w postaci www.google.com lub adresu IP za pomocą typu QHostAddress, oraz numer portu, na którym nasłuchuje serwer.
Druga z nich ustanawia odpowiednie połączenia między obiektem serwera (QTcpSocket) a naszą klasą i definiuje większość logiki naszej klasy. Za pomocą części tych połączeń dowiemy się również interesujących informacji, np. o błędach – gdy nie będziemy mogli się połączyć, to tam powinniśmy szukać informacji.
Połączenia – connect() – zrealizowałem za pomocą zapisu funktorowego z użyciem lambd (w tym lambda capture). Przykładowo połączenie:
Użycie lambd pozwala na znaczne skrócenie kodu, ale może nieco utrudnić jego analizę. Poniższa forma połączenia wynika z przeładowania sygnału w klasie QAbstractSocket (więcej w tej dokumentacji).
Użycie lambd przechwytujących w metodzie connect() jest możliwe dzięki jej przeładowaniu, w którym metoda connect ma tylko 3 argumenty (tzw. 3 arg connect). Pomimo faktu, że jest to bardzo wygodne, nie zawsze jednak zdajemy sobie sprawę, że może być szkodliwe. Użycie takiej trójargumentowej metody wymaga zapewnienia przez nas, że obiekty przechwytywane będą nadal istnieć przez czas, w którym obiekt emitujący może dany sygnał wyemitować.
Jeśli tego nie zapewnimy, nasz program może doświadczyć nieoczekiwanego przerwania go przez system na skutek naruszenia pamięci.
Sygnał messageFromServer() jest przechwytywany w QML, a informacja, jaką niesie, jest wyświetlana w oknie tekstowym. Za pomocą metody writeToServer() wysyłamy nasze wiadomości z czatu do serwera, uprzednio konwertując je do typu QByteArray. Pozostałe metody służą głównie do obsługi interfejsu.
Kod interfejsu programu klienta w pliku main.qml jest zrobiony w podobny sposób jak przykład interfejsu do obsługi AVRDUDE. I jest w dużej mierze podobny do pliku main.qml programu serwera. Istotnymi fragmentami są:
Dzięki temu, pisząc w oknie czatu, możemy wysłać wiadomość, naciskając klawisze Return lub Enter. Podobnie pliki main.cpp dla obu programów różnią się jedynie typami obiektów, jakie tworzymy i rejestrujemy w QML – w jednym obiekt klasy Server, w drugim obiekt klasy Client.
Serwer
Kod klasy Server korzysta z klasy QTcpServer, której obiekt wykorzystuje do uruchomienia serwera i obsługi przychodzących połączeń od klientów. Wykorzystuje również obiekt klasy QTcpSocket do przesyłania i odbierania danych:
Kluczowymi fragmentami tej klasy są metody setupServer() i onNewConnection() oraz konstruktor klasy Server(). Pierwsza z metod ustawia serwer w tryb nasłuchiwania przychodzących połączeń na podanym adresie i porcie. Aby nawiązać połączenie za pomocą protokołów TPC/IP, musimy posiadać zarówno adres IP, jak i numer portu.
W metodzie setupServer() każemy serwerowi nasłuchiwać na dowolnym adresie i podanym porcie. Łącząc się za pomocą klienta, podamy adres serwera i port, np.: 192.168.0.1 : 5555. W konstruktorze klasy Server ustanawiamy połączenie między sygnałem QTcpServer::newConnection a slotem w naszej klasie onNewConnection().
Najważniejszym elementem naszego serwera jest slot onNewConnection(), gdzie przyjmujemy nowe połączenia – robimy to za pomocą metody QTcpServer::nextPendingConnection(), przypisując naszemu wskaźnikowi – client (QTcpSocket*) – adres obiektu QTcpSocket nowego połączenia. Następnie wykonujemy szereg połączeń – identycznie jak w przypadku klasy Client z jednym wyjątkiem:
Zapewniamy tym samym zwolnienie zasobów po obiekcie QTcpSocket, reprezentującym jedno połączenie, gdy stan tego połączenia zmieni się na Disconnected.
Serwer może przyjąć dowolną liczbę połączeń (standardowo 30 – patrz dokumentacja). W opisywanym przypadku serwer współpracuje z najnowszym połączeniem, czyli z połączeniem klienta, który dołączył jako ostatni. Dzieje się tak, ponieważ do wskaźnika client (QTcpSocket*) przypisujemy nowe połączenie, tracąc przy tym wskaźnik na poprzednie:
C++
1
client=server.nextPendingConnection();
Poprzednie połączenie pozostaje aktywne (a tym samym zaalokowane zasoby nadal pozostają zajęte), dopóki nie wyłączymy serwera lub klient sam go nie zakończy. Oznacza to, że w sytuacji, gdybyśmy uruchomili serwer i dwukrotnie klienta, a następnie połączyli klientów z serwerem, to czat będzie aktywny między serwerem a ostatnio połączonym klientem.
Nie jest to najlepszy design, należałoby to rozwiązać zdecydowanie lepiej. Na razie możemy problem nieco zamaskować, definiując połączenie:
Dzięki temu w polu tekstowym interfejsu nie będziemy dostawać pustych wiadomości opatrzonych tylko godziną i minutą. Pozostałe metody służą głównie do obsługi interfejsu. Natomiast pliki main.cpp i main.qml są bardzo podobne do ich odpowiedników w programie klienta.
Przykład działania
Serwer w naszym programie nasłuchuje na dowolnym adresie – może to być adres nadany nam przez router (inny dla karty Wi-Fi, inny dla Ethernetu itd.), może także nasłuchiwać na lokalnym adresie komputera (127.0.0.1) bez fizycznego interfejsu sieciowego za pomocą mechanizmu loopback. Właśnie ten mechanizm wykorzystamy do połączenia naszych programów dzięki TCP/IP – uruchamiając oba programy na tej samej maszynie.
Serwer uruchamiamy, podając dowolny port – ja wybrałem jako domyślny port o numerze 5555. Klient musi podać taki sam numer portu. Jako adres serwera w programie klienta podajemy numer IP lokalnego urządzenia: 127.0.0.1 lub po prostu nazwę hosta – jako localhost. Działanie obu programów przedstawia poniższy film:
Czat w domowej sieci?
Czy mogę poczatować z innym użytkownikiem w mojej domowej sieci? Pewnie! Wystarczy podać IP komputera, na którym znajduje się serwer – możemy sprawdzić IP nadane przez router w ustawieniach sieci. U mnie IP komputera, na którym działa serwer, to 192.168.0.108 – i taki adres IP podaję w programie klienta i uruchamiam na moim telefonie, który mam podłączony do mojej lokalnej sieci Wi-Fi. Pamiętajmy o włączeniu serwera! Efekt:
Własny czat w sieci lokalnej
Zadania dodatkowe
Osoby, które chciałyby poznać lepiej ten temat, powinny:
zapoznać się z dokumentacją użytych w tej części klas Qt,
przeczytać o różnicach między procesem a wątkiem,
poeksperymentować z wykorzystaniem klasy QProcess i mechanizmami IPC.
Podsumowanie
W tej części zajęliśmy się tematem współbieżności w obrębie jednego systemu operacyjnego, czyli tematem wielozadaniowości. Poznaliśmy sposoby na uruchamianie nowych procesów i przedstawiliśmy przykład z uruchomieniem AVRDUDE w celu wgrania programu na mikrokontroler.
Czy wpis był pomocny? Oceń go:
Średnia ocena 4.9 / 5. Głosów łącznie: 18
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ć!
Następnie przedstawiliśmy sposoby na implementację komunikacji międzyprocesowej – Inter-Process Communication (IPC) – w Qt oraz zbudowaliśmy narzędzie do zrealizowania czatu za pomocą dwóch programów, korzystając z protokołów TCP/IP. W jednym przypadku TCP/IP działał jako mechanizm IPC, w drugim – jako normalny protokół sieciowy.
Autorem tej serii wpisów jest Mateusz Patyk, który zawodowo zajmuje się programowaniem systemów wbudowanych oraz rozwijaniem aplikacji na desktopy i urządzenia mobilne. Jego głównymi obszarami zainteresowań są systemy sterowania, egzoszkielety i urządzenia do wspomagania chodu człowieka. Prywatnie miłośnik dobrego kina i gier strategicznych.
Dołącz do 20 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 20 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...