Ця стаття є частиною проєкту «Інтернет-радіо на ESP32-S2 Mini»
Керівництво з налаштування та підключення
Для забезпечення стабільної роботи вашого інтернет-радіо на базі ESP32s2 необхідно дотримуватися кількох важливих кроків. Одним із ключових аспектів є встановлення радіатора на мікроконтролер, щоб уникнути перегріву та нестабільної роботи. Під час тестування було виявлено, що без радіатора пристрій може працювати ненадійно.
Підготовка та запис веб-інтерфейсу
Перед використанням інтернет-радіо необхідно записати файл index.html у пам'ять мікроконтролера. Цей файл є веб-інтерфейсом, через який ви зможете налаштовувати ім'я мережі та пароль, а також редагувати список радіостанцій. Дотримуючись інструкцій із попередньої статті, ви легко впораєтеся з цим завданням і одразу зможете використовувати веб-інтерфейс для налаштування пристрою..
Основні функції пристрою
Після встановлення веб-інтерфейсу та завантаження коду ваш пристрій отримує наступні функції:
- Регулювання гучності та вимкнення звуку кнопками: Зручне керування гучністю та можливість вимкнути звук натисканням кнопки.
- Перемикання радіостанцій: Довге натискання (1 секунда) кнопки збільшення або зменшення гучності дозволяє перемикатися між радіостанціями.
- Багатофункціональна кнопка на піні 12: Ця кнопка вимикає світлодіоди або, при тривалому натисканні, вимикає інтернет-радіо та переводить мікроконтролер у режим точки доступу для редагування списку радіостанцій або налаштування WiFi.
- Стани увімкнення: Біле свічення / витяг налаштувань WiFi із пам'яті: Помаранчеве свічення / підключення до WiFi: Зелене свічення / Відтворення першої радіостанції зі списку: Ефект вогонь.
- Увімкнення налаштування: Синє свічення / Запуск точки доступу: Ефект райдуги.
Компоненти для підключення
Для складання та роботи пристрою вам знадобляться такі компоненти:
- ESP32s2 mini: Мікроконтролер, який керує всією системою.
- Модуль підсилювача на MAX98357: Забезпечує якісне відтворення звуку.
- LED-кільце на 12 адресних світлодіодів WS2812: Для візуальних ефектів та індикації стану.
- Динамік 3 Вт (57 мм діаметр): Для відтворення звуку.
- Чотири тактові кнопки 6х6х5 мм.
- Акумуляторна батарея 18350: Забезпечує живлення пристрою та компактні розміри.
- Модуль заряджання акумулятора TP4056 Type-C із функцією захисту: Для безпечного заряджання батареї.
- Підвищувальний DC-DC модуль на 5 вольт: Забезпечує стабільну напругу для роботи пристрою.
- Мікровимикач двопозиційний: Для увімкнення та вимкнення пристрою.
- Контакти для батарей 18350.
- Надрукувати корпус (файли доступні на сайті) або замовити вже надрукований на 3D-принтері корпус.
Підключення компонентів
Підключення всіх компонентів не має викликати складнощів, оскільки теоретично розібратися з призначенням пінів за кодом буде нескладно. Ви також можете переназначити піни так, як вам буде зручніше.
Зібравши всі компоненти та виконавши всі кроки з налаштування, ви отримаєте мініатюрне та функціональне інтернет-радіо, кероване мікроконтролером ESP32s2mini. Насолоджуйтесь своїм новим пристроєм і діліться результатами!
#include <WiFi.h>
#include <LittleFS.h>
#include <Preferences.h>
#include <ArduinoJson.h>
#include <WebServer.h>
#include "AudioFileSourceHTTPStream.h"
#include "AudioFileSourceBuffer.h"
#include "AudioGeneratorMP3.h"
#include "AudioOutputI2S.h"
#include <FastLED.h>
#include <vector>
const char* defaultStreamUrl = "http://zt01.cdn.eurozet.pl/zet-net.mp3";
std::vector<String> radioStations;
int currentStationIndex = 0;
// Объекты для работы с Wi-Fi и памятью
Preferences preferences;
WebServer server(80);
AudioGeneratorMP3 *mp3;
AudioFileSourceHTTPStream *file;
AudioFileSourceBuffer *buff;
AudioOutputI2S *out;
#define MUTE_PIN 35
#define VOL_UP 37
#define VOL_DOWN 33
#define BLU_PIN 15
#define LED_PIN 11
#define RES_PIN 12
#define NUM_LEDS 12
#define BRIGHTNESS 30
CRGB leds[NUM_LEDS];
unsigned long lastReconnectAttempt = 0;
unsigned long lastLedUpdate = 0;
unsigned long lastBlinkTime = 0;
bool ledOn = true;
bool wifiStatus = false;
bool isMuted = false;
float currentVolume = 0.09;
unsigned long lastButtonPressTime = 0;
bool enableLedEffect = true;
String ledMode = "Blue";
void reconnectStream() {
if (radioStations.empty()) {
Serial.println("No radio stations available.");
return;
}
String stationUrl = radioStations[currentStationIndex];
Serial.println("Starting playback of station: " + stationUrl);
if (mp3) {
mp3->stop();
delete mp3;
mp3 = nullptr;
}
if (buff) {
delete buff;
buff = nullptr;
}
if (file) {
delete file;
file = nullptr;
}
file = new AudioFileSourceHTTPStream(stationUrl.c_str());
size_t bufferSize = ESP.getFreeHeap() / 4;
if (bufferSize < 1024) bufferSize = 1024;
buff = new AudioFileSourceBuffer(file, bufferSize);
mp3 = new AudioGeneratorMP3();
mp3->begin(buff, out);
ledMode = "Fire";
}
void connectToWiFi(String ssid, String password) {
WiFi.begin(ssid.c_str(), password.c_str());
unsigned long startAttemptTime = millis();
while (WiFi.status() != WL_CONNECTED && millis() - startAttemptTime < 10000) {
delay(500);
}
wifiStatus = (WiFi.status() == WL_CONNECTED);
if (wifiStatus) {
Serial.println("WiFi connected!");
fill_solid(leds, NUM_LEDS, CRGB::Green);
FastLED.show();
delay (1000);
} else {
Serial.println("WiFi connection failed.");
fill_solid(leds, NUM_LEDS, CRGB::Red);
FastLED.show();
delay (1000);
startAccessPoint();
}
}
void loadWiFiCredentials() {
preferences.begin("WiFi", true);
String savedSSID = preferences.getString("wifiSSID", "");
String savedPassword = preferences.getString("wifiPassword", "");
preferences.end();
if (savedSSID.length() > 0 && savedPassword.length() > 0) {
fill_solid(leds, NUM_LEDS, CRGB::Orange);
FastLED.show();
delay(1000);
connectToWiFi(savedSSID, savedPassword);
} else {
fill_solid(leds, NUM_LEDS, CRGB::Blue);
FastLED.show();
delay (1000);
startAccessPoint();
}
}
void loadStations() {
if (LittleFS.exists("/stations.json")) {
File file = LittleFS.open("/stations.json", "r");
if (file) {
StaticJsonDocument<1024> doc;
DeserializationError error = deserializeJson(doc, file);
if (!error) {
JsonArray stationArray = doc.as<JsonArray>();
for (JsonVariant v : stationArray) {
if (v.containsKey("url")) {
String url = v["url"].as<String>();
Serial.println("Found station: " + url);
radioStations.push_back(url);
}
}
if (radioStations.empty()) {
Serial.println("Station list is empty, using default.");
radioStations.push_back(defaultStreamUrl); // Добавляем дефолтный URL, если список пуст
}
Serial.println("Loaded radio stations.");
} else {
Serial.print("Failed to parse stations.json: ");
Serial.println(error.c_str());
radioStations.push_back(defaultStreamUrl); // Добавляем дефолтный URL
}
file.close();
} else {
Serial.println("Failed to open stations.json, using default.");
radioStations.push_back(defaultStreamUrl); // Добавляем дефолтный URL
}
} else {
Serial.println("stations.json not found, using default.");
radioStations.push_back(defaultStreamUrl); // Добавляем дефолтный URL
}
// Выводим все загруженные станции для проверки
Serial.println("Radio Stations:");
for (size_t i = 0; i < radioStations.size(); i++) {
Serial.printf("Station %d: %s\n", i + 1, radioStations[i].c_str());
}
}
// Функция для запуска точки доступа
void startAccessPoint() {
setSolidColor(CRGB::Blue);
delay (500);
WiFi.softAP("esp32radio", "123456");
Serial.println("Access Point mode enabled.");
server.on("/", handleRoot);
server.on("/saveWiFi", handleSaveWiFi);
server.on("/addStation", handleAddStation);
server.on("/getStations", handleGetStations);
server.on("/deleteStation", handleDeleteStation);
server.on("/restart", handleRestart);
server.begin();
Serial.println("HTTP сервер запущен.");
Serial.print("ESP32 IP Address: ");
Serial.println(WiFi.softAPIP());
delay (500);
}
void handleRoot() {
Serial.println("Handling root request...");
File file = LittleFS.open("/index.html", "r");
if (!file) {
server.send(500, "text/plain", "Error opening index file");
return;
}
server.streamFile(file, "text/html");
file.close();
Serial.println("Index file sent to client.");
}
void handleGetStations() {
File file = LittleFS.open("/stations.json", "r");
if (!file) {
server.send(500, "text/plain", "Error opening stations file");
return;
}
StaticJsonDocument<1024> doc;
DeserializationError error = deserializeJson(doc, file);
file.close();
if (error) {
server.send(500, "text/plain", "Error parsing stations file");
return;
}
String response;
serializeJson(doc, response);
server.send(200, "application/json", response);
}
void handleSaveWiFi() {
if (server.hasArg("plain") == false) {
server.send(400, "text/plain", "Body not received");
return;
}
String body = server.arg("plain");
Serial.println("Received body: " + body);
StaticJsonDocument<200> doc;
DeserializationError error = deserializeJson(doc, body);
if (error) {
server.send(400, "text/plain", "Invalid JSON");
return;
}
const char* ssid = doc["ssid"];
const char* password = doc["password"];
Serial.println("Received WiFi settings: SSID=" + String(ssid) + ", Password=" + String(password));
preferences.begin("WiFi", false);
preferences.putString("wifiSSID", ssid);
preferences.putString("wifiPassword", password);
server.send(200, "text/plain", "Wi-Fi settings saved");
}
void handleAddStation() {
if (server.hasArg("plain") == false) {
server.send(400, "text/plain", "Body not received");
return;
}
String body = server.arg("plain");
Serial.println("Received body: " + body);
StaticJsonDocument<200> doc;
DeserializationError error = deserializeJson(doc, body);
if (error) {
server.send(400, "text/plain", "Invalid JSON");
return;
}
if (!doc.containsKey("url")) {
server.send(400, "text/plain", "Missing URL");
return;
}
const char* url = doc["url"];
Serial.println("Received station URL: " + String(url));
File file = LittleFS.open("/stations.json", "r");
StaticJsonDocument<1024> stationsDoc;
JsonArray stations;
// Если файл существует и валиден
if (file) {
DeserializationError readError = deserializeJson(stationsDoc, file);
file.close();
if (readError) {
Serial.println("Error parsing stations file, creating new one.");
stations = stationsDoc.to<JsonArray>();
} else {
stations = stationsDoc.as<JsonArray>();
}
} else {
Serial.println("No stations file found, creating new one.");
stations = stationsDoc.to<JsonArray>();
}
// Проверка на количество станций <= 8
if (stations.size() >= 8) {
server.send(400, "text/plain", "Station list is full (maximum 8 stations)");
return;
}
// Добавление новой станции
JsonObject newStation = stations.createNestedObject();
newStation["url"] = url;
// Сохранение обновленного списка в файл
file = LittleFS.open("/stations.json", "w");
if (!file) {
server.send(500, "text/plain", "Error opening stations file for writing");
return;
}
serializeJson(stationsDoc, file);
file.close();
server.send(200, "text/plain", "Station added");
}
void handleDeleteStation() {
if (!server.hasArg("index")) {
server.send(400, "text/plain", "Index not provided");
return;
}
int index = server.arg("index").toInt();
File file = LittleFS.open("/stations.json", "r");
if (!file) {
server.send(500, "text/plain", "Error opening stations file");
return;
}
StaticJsonDocument<1024> stationsDoc;
deserializeJson(stationsDoc, file);
file.close();
JsonArray stations = stationsDoc.as<JsonArray>();
if (index < 0 || index >= stations.size()) {
server.send(400, "text/plain", "Invalid index");
return;
}
stations.remove(index);
file = LittleFS.open("/stations.json", "w");
if (!file) {
server.send(500, "text/plain", "Error writing stations file");
return;
}
serializeJson(stationsDoc, file);
file.close();
server.send(200, "text/plain", "Station deleted");
}
void handleRestart() {
Serial.println("Handling restart request...");
server.send(200, "text/plain", "Restarting...");
delay(1000); // Даем время отправить ответ клиенту
ESP.restart();
}
void setup() {
Serial.begin(9600);
pinMode(BLU_PIN, OUTPUT);
pinMode(MUTE_PIN, INPUT_PULLUP);
pinMode(VOL_UP, INPUT_PULLUP);
pinMode(VOL_DOWN, INPUT_PULLUP);
pinMode(RES_PIN, INPUT_PULLUP);
digitalWrite(BLU_PIN, HIGH);
delay (1000);
FastLED.addLeds<WS2812, LED_PIN, GRB>(leds, NUM_LEDS);
FastLED.setBrightness(BRIGHTNESS);
fill_solid(leds, NUM_LEDS, CRGB::White);
FastLED.show();
delay (1000);
// Инициализация LittleFS
if (!LittleFS.begin()) {
Serial.println("Failed to mount LittleFS!");
return;
}
loadWiFiCredentials();
if (wifiStatus) {
loadStations();
delay(500);
out = new AudioOutputI2S();
out->SetPinout(5, 3, 7);
out->SetGain(currentVolume);
delay(500);
reconnectStream();
}
digitalWrite(BLU_PIN, LOW);
}
void setSolidColor(const CRGB& color) {
fill_solid(leds, NUM_LEDS, color);
FastLED.show();
}
void updateLeds() {
// Обновление каждые 100 мс
if (millis() - lastLedUpdate < 100) return;
lastLedUpdate = millis();
// Если эффект выключен, гасим светодиоды и выходим
if (!enableLedEffect) {
fill_solid(leds, NUM_LEDS, CRGB::Black);
FastLED.show();
return;
}
if (isMuted) {
setSolidColor(CRGB::Green); // Логика светодиодов при выключении звука
return;
}
if (ledMode == "White") {
setSolidColor(CRGB::White);
} else if (ledMode == "Blue") {
setSolidColor(CRGB::Blue);
} else if (ledMode == "Green") {
setSolidColor(CRGB::Green);
} else if (ledMode == "Red") {
setSolidColor(CRGB::Red);
} else if (ledMode == "Fire") {
static uint8_t heat[NUM_LEDS];
for (int i = 0; i < NUM_LEDS; i++) {
heat[i] = qsub8(heat[i], random(0, 50));
}
for (int i = NUM_LEDS - 1; i >= 2; i--) {
heat[i] = (heat[i - 1] + heat[i - 2] + heat[i - 2]) / 3;
}
if (random(0, 10) > 7) {
int sparkIndex = random(0, 3);
heat[sparkIndex] = qadd8(heat[sparkIndex], random(160, 255));
}
for (int i = 0; i < NUM_LEDS; i++) {
leds[i] = HeatColor(heat[i]);
}
} else if (ledMode == "Rainbow") {
static uint8_t hue = 0;
fill_rainbow(leds, NUM_LEDS, hue, 7); // 7 - шаг изменения цвета
hue += 1; // Смещение для анимации
} else if (ledMode == "Strobe") {
static uint32_t lastBlinkTime = 0;
static bool ledOn = false;
if (millis() - lastBlinkTime >= 100) { // Интервал мигания 100 мс
lastBlinkTime = millis();
ledOn = !ledOn;
setSolidColor(ledOn ? CRGB::White : CRGB::Black);
}
return; // Strobe уже обновил FastLED
}
// Обновляем светодиоды в конце (если не вернулись раньше)
FastLED.show();
}
void blinkRedLeds() {
while (true) {
// Обновление каждые 100 мс
if (millis() - lastLedUpdate >= 100) {
lastLedUpdate = millis();
// Интервал мигания 500 мс
if (millis() - lastBlinkTime >= 500) {
lastBlinkTime = millis();
ledOn = !ledOn;
fill_solid(leds, NUM_LEDS, ledOn ? CRGB::Red : CRGB::Black);
FastLED.show();
}
}
}
}
void handleStationChange(int direction) {
if (direction > 0) {
currentStationIndex = (currentStationIndex + 1) % radioStations.size();
} else {
currentStationIndex = (currentStationIndex - 1 + radioStations.size()) % radioStations.size();
}
Serial.println("Switched to station: " + radioStations[currentStationIndex]);
reconnectStream();
}
void handleButtons() {
static unsigned long volUpPressTime = 0;
static unsigned long volDownPressTime = 0;
static unsigned long resPressTime = 0;
int muteState = digitalRead(MUTE_PIN);
int nextState = digitalRead(VOL_UP);
int prevState = digitalRead(VOL_DOWN);
int resState = digitalRead(RES_PIN); // Считываем состояние пина RES_PIN
unsigned long currentTime = millis();
// Обработка кнопки MUTE
if (muteState == LOW && currentTime - lastButtonPressTime >= 300) {
isMuted = !isMuted;
if (isMuted) {
enableLedEffect = true;
ledMode = "Green";
out->SetGain(0);
reconnectStream();
} else {
enableLedEffect = true;
ledMode = "RGB";
out->SetGain(currentVolume);
reconnectStream();
}
lastButtonPressTime = currentTime;
}
// Удержание кнопки VOL_UP
if (nextState == LOW) {
if (volUpPressTime == 0) volUpPressTime = currentTime; // Запоминаем время начала удержания
if (currentTime - volUpPressTime >= 1000) { // Если удерживаем более 1 секунды
handleStationChange(1); // Переключаем на следующую радиостанцию
volUpPressTime = 0; // Сбрасываем время удержания
lastButtonPressTime = currentTime; // Сбрасываем задержку для дребезга
}
} else if (volUpPressTime > 0) { // Если кнопку отпустили
if (currentTime - volUpPressTime < 1000) { // Если удержание меньше 1 секунды
if (isMuted) {
isMuted = false;
ledMode = "RGB";
out->SetGain(currentVolume);
reconnectStream();
}
if (currentVolume < 1.0) {
currentVolume += 0.03;
if (currentVolume > 1.0) currentVolume = 1.0;
out->SetGain(currentVolume);
}
}
volUpPressTime = 0;
}
// Удержание кнопки VOL_DOWN
if (prevState == LOW) {
if (volDownPressTime == 0) volDownPressTime = currentTime; // Запоминаем время начала удержания
if (currentTime - volDownPressTime >= 1000) { // Если удерживаем более 1 секунды
handleStationChange(-1); // Переключаем на предыдущую радиостанцию
volDownPressTime = 0; // Сбрасываем время удержания
lastButtonPressTime = currentTime; // Сбрасываем задержку для дребезга
}
} else if (volDownPressTime > 0) { // Если кнопку отпустили
if (currentTime - volDownPressTime < 1000) { // Если удержание меньше 1 секунды
if (isMuted) {
isMuted = false;
ledMode = "RGB";
out->SetGain(currentVolume);
reconnectStream();
}
if (currentVolume > 0.0) {
currentVolume -= 0.03;
if (currentVolume < 0.0) currentVolume = 0.0;
out->SetGain(currentVolume);
}
}
volDownPressTime = 0; // Сбрасываем время удержания
}
// Обработка RES_PIN
if (resState == LOW) {
if (resPressTime == 0) resPressTime = currentTime; // Запоминаем время начала удержания
if (currentTime - resPressTime >= 1000) { // Если удерживаем более 1 секунды
stopAudioStream(); // Остановка воспроизведения аудиопотока
WiFi.disconnect(); // Отключение от Wi-Fi
WiFi.mode(WIFI_OFF);
wifiStatus = false;
startAccessPoint(); // Запуск точки доступа
Serial.println("Access Point mode activated.");
resPressTime = 0; // Сбрасываем время удержания
}
} else if (resPressTime > 0) { // Если кнопку отпустили
if (currentTime - resPressTime < 1000) { // Короткое нажатие
if (!enableLedEffect) {
enableLedEffect = true;
Serial.println("LED effects enabled.");
} else {
enableLedEffect = false;
Serial.println("LED effects disabled.");
}
}
resPressTime = 0; // Сбрасываем время удержания
}
}
void stopAudioStream() {
// Останавливаем MP3 генератор
if (mp3) {
if (mp3->isRunning()) {
mp3->stop(); // Останавливаем воспроизведение
}
delete mp3; // Удаляем объект
mp3 = nullptr; // Сбрасываем указатель
}
// Очищаем буфер
if (buff) {
delete buff; // Удаляем объект буфера
buff = nullptr; // Сбрасываем указатель
}
// Очищаем источник данных
if (file) {
delete file; // Удаляем объект источника данных
file = nullptr; // Сбрасываем указатель
}
// Сбрасываем громкость на выходе
if (out) {
out->SetGain(0); // Устанавливаем громкость в 0
out->stop(); // Останавливаем выходной поток
}
// Логируем завершение
Serial.println("Audio stream stopped successfully.");
}
void loop() {
if (wifiStatus) {
if (mp3 && mp3->isRunning() && !isMuted) {
if (!mp3->loop()) {
setSolidColor(CRGB::Red);
delay(250);
reconnectStream();
}
} else {
if (ledMode != "Fire") {
ledMode = "Fire";
}
}
handleButtons();
} else {
if (ledMode != "Blue") {
ledMode = "Blue";
}
server.handleClient(); // Обработка входящих HTTP-запросов
delay(10);
}
updateLeds();
}
- У коді для керування світлодіодами прописано кілька ефектів, наприклад FIRE, який легко можна замінити на інший режим, змінивши 2 рядки в циклі loop. Ефекти та режими роботи LED запасом на майбутні варіанти візуалізації стану контролера.
- Сподіваюся, код організований достатньо логічно, а функції мають зрозумілі назви, що має полегшити читання та розуміння.
- Використовуються стандартні бібліотеки та компоненти, тож код має бути універсальним і зрозумілим для інших розробників.
- Якщо у вас виникнуть додаткові питання або знадобиться допомога, дайте знати! Успіхів із вашим проєктом!