Głównym założeniem projektu było stworzenie odbiornika, który oprócz odtwarzania strumienia sieciowego, pozwoliłby także na wyszukiwanie oraz zapamiętywanie nowych stacji. Dodatkowe wymagania to zaprojektowanie intuicyjnego i prostego interfejsu użytkownika oraz możliwość sterowania urządzeniem za pomocą pilota lub enkodera obrotowego.
Wybór podzespołów
Jako centralny element wybrany został mikrokontroler ESP32-S3 na płytce developerskiej DevKitC-1. Głównym argumentem przy wyborze tego właśnie układu była wbudowana obsługa sieci Wi-Fi (stanowiąca kluczowy element projektu) oraz wystarczająca ilość pamięci i wydajność.
Kolejną kwestią wymagającą przemyślenia okazał się sposób obsługi skompresowanych danych audio. Pierwsze próby realizacji zakładały programowe dekodowanie strumienia MP3 i wysyłanie zdekodowanego sygnału do zewnętrznego przetwornika cyfrowo-analogowego (DAC) przez magistralę I²S. Jednak z uwagi na braki w dostępnych bibliotekach (liczbę obsługiwanych formatów) oraz dużą konsumpcję zasobów mikrokontrolera, przynosiło to niezadowalające rezultaty. Ostatecznie wybór padł na dekodowanie przy użyciu wyspecjalizowanego układu, a konkretnie VS1053 firmy VLSI Solution. Jest on często stosowany w tego rodzaju aplikacjach, gdyż zapewnia obsługę praktycznie wszystkich popularnych formatów kompresji audio (MP3, WMA, OGG Vorbis, FLAC, AAC). Co ciekawe, układ obsługuje również format MIDI. Do komunikacji z mikrokontrolerem używa magistrali SPI. Bardzo istotny okazuje się fakt, że dostępne są gotowe moduły z zamontowanym układem oraz obwodami peryferyjnymi, co ułatwia zastosowanie go w prototypach.
Aby spełnić założenia dotyczące interfejsu użytkownika, urządzenie trzeba było wyposażyć w odpowiednio duży i czytelny wyświetlacz w technologii OLED. Wybór padł na model o wielkości 2,42”, rozdzielczości 128×64 pikseli, sterowany układem SSD1309. Do budowy użyty został również enkoder obrotowy oraz odbiornik podczerwieni TSOP2236.
Interfejs użytkownika
Po uruchomieniu urządzenia na wyświetlaczu pojawia się ekran startowy, a w tle następuje inicjalizacja. Po jej zakończeniu odbiornik jest gotowy do obsługi i wyświetlone zostaje główne menu (fotografia 1). Wybraną pozycję zatwierdzamy krótkim naciśnięciem gałki enkodera. Dłuższe przyciśnięcie gałki powoduje powrót do poprzedniego menu. Do sterowania można również używać przycisków kursora na pilocie zdalnego sterowania. Istnieją ponadto nieliczne funkcje dodatkowe dostępne tylko za pośrednictwem pilota.
Favorites
Na wyświetlaczu pokazana jest lista zapisanych wcześniej „ulubionych” stacji radiowych. Użytkownik może uruchomić tutaj odtwarzanie wybranej stacji, a także usunąć wybraną pozycję z listy (usunięcie z listy dostępne jest tylko za pomocą pilota).
Select by country
Na wyświetlaczu widzimy listę krajów wraz z liczbą zarejestrowanych stacji z każdego kraju. Lista posortowana została pod kątem liczby dostępnych rozgłośni. Wybranie danej pozycji przenosi nas na listę stacji radiowych wybranego kraju (posortowaną pod kątem popularności) – wskazanie którejś z nich uruchamia odtwarzanie.
Select by tag
Po wybraniu tej pozycji otrzymujemy listę tagów, czyli słów kluczowych, które opisują rozgłośnie. Przy każdym tagu widzimy liczbę zarejestrowanych rozgłośni. Słowa kluczowe są posortowane pod kątem liczby zarejestrowanych w odniesieniu do nich stacji. Wybranie konkretnej pozycji przenosi użytkownika do listy rozgłośni dla danego tagu, z której można od razu przejść do odtwarzania wybranej stacji.
Settings->Select radio list server
Mamy tu możliwość wyboru serwera usługi sieciowej udostępniającej bazę danych z informacjami o stacjach. Usługa ta jest dostępna na kilku serwerach. W razie awarii jednego z nich możemy w tym miejscu przełączyć się na inny.
Settings->Wifi settings
Użytkownik może tutaj wybrać z listy sieć Wi-Fi, do której ma być podłączone urządzenie. Po wybraniu odpowiedniej sieci należy wprowadzić hasło dostępowe. Zatwierdzamy je długim przyciśnięciem gałki enkodera.
Settings->Restart
Wybierając tę opcję, można zrestartować urządzenie.
Turn off
Po wybraniu tej opcji odbiornik przechodzi w tryb standby.
Ekran odtwarzania
Podczas odtwarzania na wyświetlaczu (fotografia 2) widoczne są:
- nazwa odtwarzanej stacji,
- kraj odtwarzanej stacji,
- bitrate [kbps],
- ustawiona głośność,
- liczba bajtów w buforze odtwarzania (aktualizowana cały czas),
- napis „chunked”, jeśli strumień transmitowany jest w takim właśnie trybie.
W trakcie odtwarzania użytkownik może dodać stację do listy ulubionych. Funkcja ta dostępna jest tylko za pomocą pilota. Możliwa jest również regulacja głośności odtwarzania.
Usługa radio-browser.info
Jedno z głównych założeń projektu zakłada możliwość wyszukiwania nowych stacji. Do zrealizowania tej funkcjonalności potrzebna jest baza danych zawierająca aktualne informacje i udostępniająca interfejs (API – Application Programming Interface), przez który będziemy w stanie pobrać potrzebne dane. Powyższe założenia spełnia serwis radio-browser.info. Jest to w pełni darmowa usługa utrzymywana przez społeczność internetową. Obecnie w serwisie zarejestrowanych jest ponad 47 tysięcy rozgłośni. Użytkownik może wyszukiwać stacje po różnych kryteriach, takich jak: kraj, popularność, powiązane tagi, język, kodek itp. Dostępna jest również opcja dodawania nowych stacji do bazy.
Komunikacja z API serwisu odbywa się przez protokół HTTP i nie jest wymagane zakładanie konta ani żadna inna metoda uwierzytelniania. Użytkownicy otrzymują rozbudowane opcje dotyczące stronicowania, sortowania i filtrowania zwracanych danych. Parametry zapytań do serwisu można przekazywać na dwa sposoby:
- W adresie URL żądania HTTP GET (przez Query String Parameters – parametry oddzielone znakiem ‘&’).
- W zawartości (payload) żądania HTTP POST, w którym lista parametrów musi być zapisana w formacie JSON (JavaScript Object Notation).
Przykładowy URL żądania HTTP GET (stanowiącego zapytanie o listę stacji z danego kraju) wygląda następująco:
- https://nl1.api.radio-browser.info/json/stations/search?limit=10&countrycode=PL&order=clickcount&reverse=true, przy czym po znaku ‘?’ podane są następujące parametry:
- limit=10 (ograniczenie liczby zwróconych rekordów do 10),
- countrycode=PL (tylko polskie stacje),
- order=clickcount (sortuj po liczbie kliknięć (popularności)),
- reverse=true (sortuj malejąco).
Dane z serwisu zwracane są w formacie JSON, a ich przykładowa postać widnieje na listingu 1.
{
"changeuuid": "8125d095-6397-4660-a5c4-ef0254f67e06",
"stationuuid": "9617a958-0601-11e8-ae97-52543be04c81",
"serveruuid": null,
"name": "Radio Paradise (320k)",
"url": "http://stream-uk1.radioparadise.com/aac-320",
"url_resolved": "http://stream-uk1.radioparadise.com/aac-320",
"homepage": "https://www.radioparadise.com/",
"favicon": "https://www.radioparadise.com/favicon-32x32.png",
"tags": "california,eclectic,free,internet,non-commercial,paradise,radio",
"country": "The United States Of America",
"countrycode": "US",
"iso_3166_2": null,
"state": "California",
"language": "english",
"languagecodes": "en",
"votes": 186524,
"lastchangetime": "2023-11-04 14:11:01",
"lastchangetime_iso8601": "2023-11-04T14:11:01Z",
"codec": "AAC",
"bitrate": 320,
"hls": 0,
"lastcheckok": 1,
"lastchecktime": "2024-04-09 08:25:52",
"lastchecktime_iso8601": "2024-04-09T08:25:52Z",
"lastcheckoktime": "2024-04-09 08:25:52",
"lastcheckoktime_iso8601": "2024-04-09T08:25:52Z",
"lastlocalchecktime": "2024-04-09 08:25:52",
"lastlocalchecktime_iso8601": "2024-04-09T08:25:52Z",
"clicktimestamp": "2024-04-09 20:26:41",
"clicktimestamp_iso8601": "2024-04-09T20:26:41Z",
"clickcount": 1570,
"clicktrend": 28,
"ssl_error": 0,
"geo_lat": null,
"geo_long": null,
"has_extended_info": false
}
]
Listing 1. Odpowiedź w formacie JSON
Zachęcam do zapoznania się z dokumentacją API usługi radio-browser.info, gdyż serwis ten oferuje wiele funkcjonalności i może być bardzo pomocny przy budowie podobnych projektów.
Oprogramowanie
Na początku programu wywoływana jest funkcja Setup(), w której następuje inicjalizacja potrzebnych zasobów oraz wczytywana jest konfiguracja przechowywana w postaci pliku JSON, w pamięci flash mikrokontrolera. Do zapisu i odczytu konfiguracji użyty został system plików SPIFFS (SPI Flash File System), zaimplementowany w mikrokontrolerach firmy Espressif.
Plik konfiguracyjny zawiera informacje, takie jak: dane dostępowe do sieci Wi-Fi, nazwa serwera usługi radio-browser.info oraz lista zapisanych stacji. Fragment struktury opisującej konfigurację pokazany jest na listingu 2.
{
String serverName;
String wifiName;
String wifiPassword;
vector<RadioStationDTO> favorites;
//...
}
Listing 2. Fragment struktury opisującej konfigurację
Bardzo dużą rolę w naszym programie odgrywa obsługa formatu JSON. Zarówno plik konfiguracyjny, jak i dane z zewnętrznego serwisu są przekazywane w tym właśnie formacie. Dlatego istotne jest zaimplementowanie serializacji (zapisu obiektu do formatu JSON) oraz deserializacji (odtworzenia obiektu z postaci JSON). Z pomocą przychodzi tutaj popularna biblioteka ArduinoJson, która zapewnia obsługę potrzebnych operacji. Na listingu 3 widoczne są funkcje serializujące i deserializujące dane konfiguracyjne.
{
DynamicJsonDocument configJson(8192);
configJson["serverName"] = serverName;
configJson["wifiName"] = wifiName;
configJson["wifiPassword"] = wifiPassword;
JsonArray favoritesArray = configJson.createNestedArray("favorites");
for (const auto &station : favorites)
{
JsonObject stationObject = favoritesArray.createNestedObject();
stationObject["name"] = station.Name;
stationObject["url"] = station.Url;
stationObject["bitrate"] = station.Bitrate;
stationObject["country"] = station.Country;
stationObject["radiofrequency"] = station.RadioFrequency;
}
String jsonString;
serializeJson(configJson, jsonString);
return jsonString;
}
void DeserializeDeviceConfiguration(const char *jsonString)
{
const size_t capacity = JSON_OBJECT_SIZE(4) + 160000;
DynamicJsonDocument jsonDocument(capacity);
DeserializationError error = deserializeJson(jsonDocument, jsonString);
if (error)
{
Serial.print("Json serialization errror: ");
Serial.println(error.c_str());
return;
}
serverName = jsonDocument["serverName"].as<String>();
wifiName = jsonDocument["wifiName"].as<String>();
wifiPassword = jsonDocument["wifiPassword"].as<String>();
favorites.clear();
JsonArray favoritesArray = jsonDocument["favorites"];
for (const auto &station : favoritesArray)
{
RadioStationDTO favoriteStation;
favoriteStation.Bitrate = station["bitrate"];
favoriteStation.Country = station["country"].as<String>();
favoriteStation.Name = station["name"].as<String>();
favoriteStation.RadioFrequency = station["radiofrequency"].as<String>();
favoriteStation.Url = station["url"].as<String>();
favorites.push_back(favoriteStation);
}
}
Listing 3. Funkcja serializująca i deserializująca konfigurację
Komunikacja z serwisem radio-browser.info zaimplementowana została w klasie RadioListHttpClient. Znajdują się tam metody, takie jak: GetTags(), GetCountries(), GetRadioURLsByCountry(), GetRadioURLsByTag(). Odpowiadają one za pobieranie danych z tego serwisu. W przypadku każdej metody proces przebiega według schematu:
- Przygotowanie odpowiedniego URL zawierającego potrzebne parametry (należy pamiętać, aby odpowiednio zakodować znaki specjalne – funkcja urlEncode).
- Wysłanie zapytania HTTP GET (metoda GET() klasy HTTPClient).
- Przekazanie strumienia HTTP do funkcji deserializeJson.
- Utworzenie potrzebnych obiektów klas DTO (Data Transfer Object) na podstawie obiektu klasy DynamicJsonDocument.
- Zwolnienie zasobów (httpClient.end() oraz jsonDoc.clear()).
Na listingu 4 ukazana jest funkcja GetCountries().
{
vector<CountryDTO> outList;
DynamicJsonDocument jsonDoc(8072);
char urlText[400];
sprintf(urlText, "https://%s/json/countries?order=stationcount&limit=%i&reverse=true&offset=%i", _config->serverName.c_str(), countriesPerPage, countriesPerPage * countriesPageIndex);
httpClient.begin(urlEncode(urlText));
httpClient.GET();
deserializeJson(jsonDoc, httpClient.getStream());
httpClient.end();
for (auto item : jsonDoc.as<JsonArray>())
{
const char *name = item["name"];
const char *code = item["iso_3166_1"];
int stationsCount = item["stationcount"];
CountryDTO c;
c.name = name;
c.code = code;
c.count = stationsCount;
outList.push_back(c);
}
jsonDoc.clear();
return outList;
}
Listing 4. Funkcja pobierająca listę krajów
Wspomniane wyżej klasy DTO służą do przechowywania informacji o stacjach, krajach oraz tagach (słowach kluczowych). Mają one postać jak na listingu 5.
{
public:
String Name;
String Url;
int Bitrate;
String RadioFrequency;
String Country;
};
class CountryDTO
{
public:
String code;
String name;
int count;
};
class TagDTO
{
public:
String name;
int count;
};
Listing 5. Klasy DTO
Kolejny wart omówienia fragment firmware to proces odtwarzania strumienia. Najważniejsza jego część znajduje się w pliku HttpWebRadioClient.h. Na początku procesu odtwarzania tworzony jest task RTOS, w którym następuje podłączenie do wybranego serwera oraz wywołanie funkcji HTTP GET. Następnie odczytywane są nagłówki HTTP i pobierany jest wskaźnik do strumienia audio. Dane ze strumienia trafiają do bufora kołowego (circular buffer). Zawartość bufora kołowego (o ile nie jest pusty) jest cały czas wysyłana na magistralę SPI do układu dekodującego (VS1053). Odbywa się to w osobnym tasku RTOS, inicjalizowanym tylko raz, przy starcie urządzenia. Warto dodać, że istotna okazuje się tutaj synchronizacja dostępu do bufora kołowego, gdyż może zdarzyć się sytuacja, w której dwa zadania RTOS będą próbowały jednocześnie uzyskać do niego dostęp. W tym celu, w tzw. sekcji krytycznej użyta została klasa mutex i metody lock oraz unlock. Brak takiego zabezpieczenia powodował słyszalne błędy przy odtwarzaniu strumienia.
Warto zwrócić uwagę, że niektóre serwery wysyłają strumień w trybie chunked stream. Można to rozpoznać, gdy – po wysłaniu żądania HTTP GET – w nagłówku Transfer-Encoding otrzymamy wartość chunked. W trybie tym dane w strumieniu podzielone są na bloki. Każdy blok poprzedza informacja o jego długości (zapisana heksadecymalnie w ASCII) oraz sekwencja [CR][LF] (szesnastkowo [0x0D] [0x0A]). Koniec bloku danych również oznaczony jest sekwencją [CR][LF]. Transmisja kończy się pakietem o zerowej wielkości. Jeśli nie zinterpretujemy poprawnie takiego strumienia, w sygnale audio będą słyszalne nieprzyjemne zniekształcenia.
public:
virtual void HandleLoop() = 0;
virtual void HandleUp() = 0;
virtual void HandleDown() = 0;
virtual void HandleLeft() = 0;
virtual void HandleRight() = 0;
virtual bool HandleEnter()= 0;
virtual bool HandleBack() = 0;
};
Listing 6. Klasa abstrakcyjna DeviceStateBase
Interfejs użytkownika jest obsługiwany przez klasy dziedziczące po klasie abstrakcyjnej DeviceStateBase widocznej na listingu 6. Umieszczone są one w podkatalogu /lib/DeviceStates. Znajdziemy w nich metody obsługujące zdarzenia wygenerowane przez użytkownika oraz wyświetlanie informacji na ekranie OLED. Każda z tych klas odpowiada za konkretny stan, w jakim może się znaleźć urządzenie. Listę stanów reprezentuje typ wyliczeniowy UIState (listing 7).
{
MODE_SELECT,
SELECT_TAG,
SELECT_COUNTRY,
SELECT_STATION,
SELECT_FAVORITES,
PLAY,
DEVICE_START,
SELECT_SETTINGS,
SELECT_SERVER_SETTING,
SELECT_WIFI_SETTING,
ENTER_PASSWORD,
RESTART,
SLEEP
};
Listing 7. Typ wyliczeniowy UIState
Przykładowe implementacje metod wirtualnych możemy znaleźć na listingu 8. W każdej z nich najpierw sprawdzany jest stan, w jakim znajduje się urządzenie (w tym przypadku SELECT_TAG), a później wykonywana jest logika danej funkcji.
{
if (*_currentState != SELECT_TAG)
return false;
*_currentState = _stationsListState->EnterState(SELECT_TAG, "", tags[currentIndex].name);
return true;
}
void TagsListState::HandleRight()
{
if (*_currentState != SELECT_TAG)
return;
_radioListClient->SetNextTagsPage();
GetTagsPage();
currentIndex = 0;
}
void TagsListState::HandleLeft()
{
if (*_currentState != SELECT_TAG)
return;
_radioListClient->SetPrevTagsPage();
GetTagsPage();
currentIndex = 0;
}
Listing 8. Implementacje metod wirtualnych w klasie TagsListState
Obsługa pilota zdalnego sterowania zrealizowana została za pomocą biblioteki IRemote, pozwalającej na dekodowanie większości protokołów używanych we współczesnych pilotach. Przy inicjalizacji wystarczy tylko podać pin mikrokontrolera, do którego podłączono odbiornik podczerwieni. Następnie należy w pętli wywoływać metodę decode() – zwraca ona wartość true, jeśli sygnał z pilota zostanie odebrany i zdekodowany. Po zdekodowaniu mamy dostęp do informacji, takich jak: typ protokołu, adres i komenda. Można również pobrać surowe dane odebrane przez odbiornik. Na podstawie wykrytych komend wywoływane są odpowiednie metody klas obsługujących stany urządzenia.
Wyłączenie odbiornika realizowane jest przez przejście mikrokontrolera w tryb deep sleep. Wybudzenie na przyciśnięcie gałki enkodera zostało skonfigurowane za pomocą funkcji esp_sleep_enable_ext0_wakeup.
Cały proces developmentu odbył się z użyciem edytora Visual Studio Code oraz frameworku PlatformIO. Zawartość pliku inicjalizacyjnego platformio.ini widoczna jest na listingu 9.
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
lib_deps =
baldram/ESP_VS1053_Library@^1.1.4
bblanchon/ArduinoJson@^6.21.3
rlogiacco/CircularBuffer@^1.3.3
olikraus/U8g2@^2.35.4
igorantolic/Ai Esp32 Rotary Encoder@^1.6
z3t0/IRremote@^4.2.0
monitor_speed = 115200
Listing 9. Plik inicjalizacyjny platformio.ini
Znajdują się w nim dane dotyczące modelu mikrokontrolera, lista zależności (czyli użytych zewnętrznych bibliotek) oraz inne dane konfiguracyjne. Dokumentacja dotycząca pliku platformio.ini znajduje się na stronie: https://docs.platformio.org/page/projectconf.html.
Montaż i uruchomienie
Schemat ideowy urządzenia pokazano na rysunku 1.
Odbiornik zasilany jest napięciem 5 V, filtrowanym przez kondensatory C1...C4 i doprowadzonym do płytki mikrokontrolera oraz do modułu dekodera. Wyświetlacz i enkoder obrotowy zasilane są z wyjścia 3,3 V na płytce mikrokontrolera. Wyjście audio stanowi gniazdo jack 3,5 mm umieszczone na płytce z układem dekodera. Wyświetlacz i układ VS1053 komunikują się z mikrokontrolerem za pośrednictwem dwóch oddzielnych magistral SPI. Zasilanie sygnalizuje dioda LED podłączona przez rezystor R1. Odbiornik podczerwieni TSOP2236 – zasilany napięciem 5 V – podłączony jest do pinu GPIO41 mikrokontrolera.
Prototyp został zrealizowany na uniwersalnej płytce drukowanej (fotografia 3), na co pozwoliła mała liczba komponentów potrzebnych do budowy. Obudowę zaprojektowano w aplikacji Fusion360, a następnie wydrukowano na drukarce 3D przy użyciu filamentu PLA.
Podsumowanie
Repozytorium projektu zostało umieszczone w serwisie GitHub i jest publicznie dostępne. Gorąco zachęcam do budowy podobnych urządzeń i mam nadzieję, że rozwiązania opisane w tym artykule okażą się pomocne.
Paweł Ciuraj
- ESP32-S3 DevKitC-1 (1 szt.)
- Rezystor 220 Ω (1 szt.)
- Dioda LED (1 szt.)
- Wyświetlacz OLED 2.42” SSD1309 (1 szt.)
- Enkoder obrotowy z przyciskiem (1 szt.)
- Odbiornik podczerwieni TSOP2236 (1 szt.)
- Moduł VS1053 (1 szt.)
- Kondensator 100 μF/16 V (2 szt.)
- Kondensator 100 nF (2 szt.)